Merge changes I584aa5d6,I61b42965 into main

* changes:
  tracing_service: Make AID_SYSTEM a privileged initiator
  tracing_service: AID_STATSD is a privileged initiator only on android
diff --git a/Android.bp b/Android.bp
index ae8225d..c43694e 100644
--- a/Android.bp
+++ b/Android.bp
@@ -13477,6 +13477,7 @@
         "src/trace_redaction/redact_process_free.cc",
         "src/trace_redaction/redact_sched_switch.cc",
         "src/trace_redaction/redact_task_newtask.cc",
+        "src/trace_redaction/remap_scheduling_events.cc",
         "src/trace_redaction/scrub_ftrace_events.cc",
         "src/trace_redaction/scrub_process_stats.cc",
         "src/trace_redaction/scrub_process_trees.cc",
@@ -13505,6 +13506,7 @@
         "src/trace_redaction/redact_process_free_unittest.cc",
         "src/trace_redaction/redact_sched_switch_unittest.cc",
         "src/trace_redaction/redact_task_newtask_unittest.cc",
+        "src/trace_redaction/remap_scheduling_events_unittest.cc",
         "src/trace_redaction/suspend_resume_unittest.cc",
     ],
 }
diff --git a/CHANGELOG b/CHANGELOG
index eb1dab5..407a08d 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -2,7 +2,8 @@
   Tracing service and probes:
     *
   Trace Processor:
-    *
+    * Optimised single column `DISTINCT` queries.
+    * Optimised `LIMIT` and `OFFSET` queries.
   SQL Standard library:
     * Improved support for querying startups on Android 9 (API level 28) and
       below. Available in `android.startup.startups` module.
diff --git a/protos/perfetto/trace/perfetto_trace.proto b/protos/perfetto/trace/perfetto_trace.proto
index 6d7c098..e280127 100644
--- a/protos/perfetto/trace/perfetto_trace.proto
+++ b/protos/perfetto/trace/perfetto_trace.proto
@@ -14941,6 +14941,7 @@
     ShellHandlerMappings shell_handler_mappings = 97;
     ProtoLogMessage protolog_message = 104;
     ProtoLogViewerConfig protolog_viewer_config = 105;
+    WinscopeExtensions winscope_extensions = 112;
 
     // Events from the Windows etw infrastructure.
     EtwTraceEventBundle etw_events = 95;
@@ -14969,8 +14970,6 @@
     TestEvent for_testing = 900;
   }
 
-  optional WinscopeExtensions winscope_extensions = 112;
-
   // Trusted user id of the producer which generated this packet. Keep in sync
   // with TrustedPacket.trusted_uid.
   //
diff --git a/protos/perfetto/trace/trace_packet.proto b/protos/perfetto/trace/trace_packet.proto
index 974f6d3..c7426ee 100644
--- a/protos/perfetto/trace/trace_packet.proto
+++ b/protos/perfetto/trace/trace_packet.proto
@@ -229,6 +229,7 @@
     ShellHandlerMappings shell_handler_mappings = 97;
     ProtoLogMessage protolog_message = 104;
     ProtoLogViewerConfig protolog_viewer_config = 105;
+    WinscopeExtensions winscope_extensions = 112;
 
     // Events from the Windows etw infrastructure.
     EtwTraceEventBundle etw_events = 95;
@@ -257,8 +258,6 @@
     TestEvent for_testing = 900;
   }
 
-  optional WinscopeExtensions winscope_extensions = 112;
-
   // Trusted user id of the producer which generated this packet. Keep in sync
   // with TrustedPacket.trusted_uid.
   //
diff --git a/python/tools/install_test_reporter_app.py b/python/tools/install_test_reporter_app.py
new file mode 100755
index 0000000..8673bbe
--- /dev/null
+++ b/python/tools/install_test_reporter_app.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+# Copyright (C) 2024 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.
+
+import argparse
+import os
+import re
+import sys
+import tempfile
+import time
+
+from perfetto.prebuilts.perfetto_prebuilts import *
+
+PERMSISION_REGEX = re.compile(r'''uses-permission: name='(.*)'.*''')
+NAME_REGEX = re.compile(r'''package: name='(.*?)' .*''')
+
+
+def cmd(args: list[str]):
+  print('Running command ' + ' '.join(args))
+  return subprocess.check_output(args)
+
+
+def main():
+  parser = argparse.ArgumentParser()
+  parser.add_argument('--apk', help='Local APK to use instead of builtin')
+
+  args = parser.parse_args()
+
+  if args.apk:
+    apk = args.apk
+  else:
+    apk = download_or_get_cached(
+        'CtsPerfettoReporterApp.apk',
+        'https://storage.googleapis.com/perfetto/CtsPerfettoReporterApp.apk',
+        'f21dda36668c368793500b13724ab2a6231d12ded05746f7cfaaba4adedd7d46')
+
+  # Figure out the package name and the permissions we need
+  aapt = subprocess.check_output(['aapt', 'dump', 'badging', apk]).decode()
+  permission_names = []
+  name = ''
+  for l in aapt.splitlines():
+    name_match = NAME_REGEX.match(l)
+    if name_match:
+      name = name_match[1]
+      continue
+
+    permission_match = PERMSISION_REGEX.match(l)
+    if permission_match:
+      permission_names.append(permission_match[1])
+      continue
+
+  # Root and remount the device.
+  cmd(['adb', 'root'])
+  cmd(['adb', 'wait-for-device'])
+  cmd(['adb', 'remount', '-R'])
+  input('The device might now reboot. If so, please unlock the device then '
+        'press enter to continue')
+  cmd(['adb', 'wait-for-device'])
+  cmd(['adb', 'root'])
+  cmd(['adb', 'wait-for-device'])
+  cmd(['adb', 'remount', '-R'])
+
+  # Write out the permission file on device.
+  permissions = '\n'.join(
+      f'''<permission name='{p}' />''' for p in permission_names)
+  permission_file_contents = f'''
+    <permissions>
+      <privapp-permissions package="{name}">
+        {permissions}
+      </privapp-permissions>
+    </permissions>
+  '''
+  with tempfile.NamedTemporaryFile() as f:
+    f.write(permission_file_contents.encode())
+    f.flush()
+
+    cmd([
+        'adb', 'push', f.name,
+        f'/system/etc/permissions/privapp-permissions-{name}.xml'
+    ])
+
+  # Stop system_server, push the apk on device and restart system_server
+  priv_app_path = f'/system/priv-app/{name}/{name}.apk'
+  cmd(['adb', 'shell', 'stop'])
+  cmd(['adb', 'push', apk, priv_app_path])
+  cmd(['adb', 'shell', 'start'])
+  cmd(['adb', 'wait-for-device'])
+  time.sleep(10)
+
+  # Wait for system_server and package manager to come up.
+  while 'system_server' not in cmd(['adb', 'shell', 'ps']).decode():
+    time.sleep(1)
+  while True:
+    ps = set([
+        l.strip()
+        for l in cmd(['adb', 'shell', 'dumpsys', '-l']).decode().splitlines()
+    ])
+    if 'storaged' in ps and 'settings' in ps and 'package' in ps:
+      break
+    time.sleep(1)
+
+  # Install the actual APK.
+  cmd(['adb', 'shell', 'pm', 'install', '-r', '-d', '-g', '-t', priv_app_path])
+
+  return 0
+
+
+sys.exit(main())
diff --git a/src/trace_processor/importers/common/process_tracker.cc b/src/trace_processor/importers/common/process_tracker.cc
index f034867..3fc95fc 100644
--- a/src/trace_processor/importers/common/process_tracker.cc
+++ b/src/trace_processor/importers/common/process_tracker.cc
@@ -16,12 +16,21 @@
 
 #include "src/trace_processor/importers/common/process_tracker.h"
 
+#include <algorithm>
+#include <cstdint>
+#include <optional>
 #include <utility>
+#include <vector>
 
+#include "perfetto/base/logging.h"
+#include "perfetto/ext/base/string_view.h"
+#include "perfetto/public/compiler.h"
 #include "src/trace_processor/storage/stats.h"
+#include "src/trace_processor/storage/trace_storage.h"
+#include "src/trace_processor/tables/metadata_tables_py.h"
+#include "src/trace_processor/types/trace_processor_context.h"
 
-namespace perfetto {
-namespace trace_processor {
+namespace perfetto::trace_processor {
 
 ProcessTracker::ProcessTracker(TraceProcessorContext* context)
     : context_(context), args_tracker_(context) {
@@ -187,11 +196,8 @@
 
   // If the process has been replaced in |pids_|, this thread is dead.
   uint32_t current_pid = processes->pid()[current_upid];
-  auto pid_it = pids_.Find(current_pid);
-  if (pid_it && *pid_it != current_upid)
-    return false;
-
-  return true;
+  auto* pid_it = pids_.Find(current_pid);
+  return !pid_it || *pid_it == current_upid;
 }
 
 std::optional<UniqueTid> ProcessTracker::GetThreadOrNull(
@@ -200,7 +206,7 @@
   auto* threads = context_->storage->mutable_thread_table();
   auto* processes = context_->storage->mutable_process_table();
 
-  auto vector_it = tids_.Find(tid);
+  auto* vector_it = tids_.Find(tid);
   if (!vector_it)
     return std::nullopt;
 
@@ -576,5 +582,4 @@
   namespaced_threads_[tid] = {pid, tid, std::move(nstid)};
 }
 
-}  // namespace trace_processor
-}  // namespace perfetto
+}  // namespace perfetto::trace_processor
diff --git a/src/trace_processor/importers/common/process_tracker.h b/src/trace_processor/importers/common/process_tracker.h
index e5ccd39..fa2ffa4 100644
--- a/src/trace_processor/importers/common/process_tracker.h
+++ b/src/trace_processor/importers/common/process_tracker.h
@@ -17,10 +17,11 @@
 #ifndef SRC_TRACE_PROCESSOR_IMPORTERS_COMMON_PROCESS_TRACKER_H_
 #define SRC_TRACE_PROCESSOR_IMPORTERS_COMMON_PROCESS_TRACKER_H_
 
-#include <stdint.h>
-
+#include <cstdint>
 #include <optional>
+#include <unordered_map>
 #include <unordered_set>
+#include <utility>
 #include <vector>
 
 #include "perfetto/ext/base/flat_hash_map.h"
@@ -96,7 +97,7 @@
   // Called when a thread is seen the process tree. Retrieves the matching utid
   // for the tid and the matching upid for the tgid and stores both.
   // Virtual for testing.
-  virtual UniqueTid UpdateThread(uint32_t tid, uint32_t tgid);
+  virtual UniqueTid UpdateThread(uint32_t tid, uint32_t pid);
 
   // Associates trusted_pid with track UUID.
   void UpdateTrustedPid(uint32_t trusted_pid, uint64_t uuid);
diff --git a/src/trace_processor/perfetto_sql/stdlib/android/battery_stats.sql b/src/trace_processor/perfetto_sql/stdlib/android/battery_stats.sql
index ff69029..9431896 100644
--- a/src/trace_processor/perfetto_sql/stdlib/android/battery_stats.sql
+++ b/src/trace_processor/perfetto_sql/stdlib/android/battery_stats.sql
@@ -119,8 +119,10 @@
 CREATE PERFETTO VIEW android_battery_stats_state(
   -- Timestamp in nanoseconds.
   ts INT,
-  -- The duration the state was active.
+  -- The duration the state was active, may be negative for incomplete slices.
   dur INT,
+  -- The same as `dur`, but extends to trace end for incomplete slices.
+  safe_dur INT,
   -- The name of the counter track.
   track_name STRING,
   -- The counter value as a number.
@@ -131,6 +133,7 @@
 SELECT
   ts,
   IFNULL(LEAD(ts) OVER (PARTITION BY name ORDER BY ts) - ts, -1) AS dur,
+  LEAD(ts, 1, TRACE_END()) OVER (PARTITION BY name ORDER BY ts) - ts AS safe_dur,
   name AS track_name,
   CAST(value AS INT64) AS value,
   android_battery_stats_counter_to_string(name, value) AS value_name
@@ -160,8 +163,10 @@
 CREATE PERFETTO VIEW android_battery_stats_event_slices(
   -- Timestamp in nanoseconds.
   ts INT,
-  -- The duration the state was active.
+  -- The duration the state was active, may be negative for incomplete slices.
   dur INT,
+  -- The same as `dur`, but extends to trace end for incomplete slices.
+  safe_dur INT,
   -- The name of the counter track.
   track_name STRING,
   -- String value.
@@ -208,6 +213,7 @@
 SELECT
   ts,
   IFNULL(end_ts-ts, -1) AS dur,
+  IFNULL(end_ts, TRACE_END()) - ts AS safe_dur,
   track_name,
   str_split(key, '"', 1) AS str_value,
   CAST(str_split(key, ':', 0) AS INT64) AS int_value
diff --git a/src/trace_processor/rpc/httpd.cc b/src/trace_processor/rpc/httpd.cc
index e63bdb4..21ca077 100644
--- a/src/trace_processor/rpc/httpd.cc
+++ b/src/trace_processor/rpc/httpd.cc
@@ -68,28 +68,28 @@
   base::HttpServer http_srv_;
 };
 
-base::HttpServerConnection* g_cur_conn;
-
 base::StringView Vec2Sv(const std::vector<uint8_t>& v) {
   return {reinterpret_cast<const char*>(v.data()), v.size()};
 }
 
 // Used both by websockets and /rpc chunked HTTP endpoints.
-void SendRpcChunk(const void* data, uint32_t len) {
+void SendRpcChunk(base::HttpServerConnection* conn,
+                  const void* data,
+                  uint32_t len) {
   if (data == nullptr) {
     // Unrecoverable RPC error case.
-    if (!g_cur_conn->is_websocket())
-      g_cur_conn->SendResponseBody("0\r\n\r\n", 5);
-    g_cur_conn->Close();
+    if (!conn->is_websocket())
+      conn->SendResponseBody("0\r\n\r\n", 5);
+    conn->Close();
     return;
   }
-  if (g_cur_conn->is_websocket()) {
-    g_cur_conn->SendWebsocketMessage(data, len);
+  if (conn->is_websocket()) {
+    conn->SendWebsocketMessage(data, len);
   } else {
     base::StackString<32> chunk_hdr("%x\r\n", len);
-    g_cur_conn->SendResponseBody(chunk_hdr.c_str(), chunk_hdr.len());
-    g_cur_conn->SendResponseBody(data, len);
-    g_cur_conn->SendResponseBody("\r\n", 2);
+    conn->SendResponseBody(chunk_hdr.c_str(), chunk_hdr.len());
+    conn->SendResponseBody(data, len);
+    conn->SendResponseBody("\r\n", 2);
   }
 }
 
@@ -165,13 +165,13 @@
     // Start the chunked reply.
     conn.SendResponseHeaders("200 OK", chunked_headers,
                              base::HttpServerConnection::kOmitContentLength);
-    PERFETTO_CHECK(g_cur_conn == nullptr);
-    g_cur_conn = req.conn;
-    global_trace_processor_rpc_.SetRpcResponseFunction(SendRpcChunk);
+    global_trace_processor_rpc_.SetRpcResponseFunction(
+        [&](const void* data, uint32_t len) {
+          SendRpcChunk(&conn, data, len);
+        });
     // OnRpcRequest() will call SendRpcChunk() one or more times.
     global_trace_processor_rpc_.OnRpcRequest(req.body.data(), req.body.size());
     global_trace_processor_rpc_.SetRpcResponseFunction(nullptr);
-    g_cur_conn = nullptr;
 
     // Terminate chunked stream.
     conn.SendResponseBody("0\r\n\r\n", 5);
@@ -249,13 +249,13 @@
 }
 
 void Httpd::OnWebsocketMessage(const base::WebsocketMessage& msg) {
-  PERFETTO_CHECK(g_cur_conn == nullptr);
-  g_cur_conn = msg.conn;
-  global_trace_processor_rpc_.SetRpcResponseFunction(SendRpcChunk);
+  global_trace_processor_rpc_.SetRpcResponseFunction(
+      [&](const void* data, uint32_t len) {
+        SendRpcChunk(msg.conn, data, len);
+      });
   // OnRpcRequest() will call SendRpcChunk() one or more times.
   global_trace_processor_rpc_.OnRpcRequest(msg.data.data(), msg.data.size());
   global_trace_processor_rpc_.SetRpcResponseFunction(nullptr);
-  g_cur_conn = nullptr;
 }
 
 }  // namespace
diff --git a/src/trace_processor/rpc/rpc.cc b/src/trace_processor/rpc/rpc.cc
index c715784..b676e45 100644
--- a/src/trace_processor/rpc/rpc.cc
+++ b/src/trace_processor/rpc/rpc.cc
@@ -504,8 +504,7 @@
   protozero::HeapBuffered<protos::pbzero::StatusResult> status;
   status->set_loaded_trace_name(trace_processor_->GetCurrentTraceName());
   status->set_human_readable_version(base::GetVersionString());
-  const char* version_code = base::GetVersionCode();
-  if (version_code) {
+  if (const char* version_code = base::GetVersionCode(); version_code) {
     status->set_version_code(version_code);
   }
   status->set_api_version(protos::pbzero::TRACE_PROCESSOR_CURRENT_API_VERSION);
diff --git a/src/trace_processor/rpc/rpc.h b/src/trace_processor/rpc/rpc.h
index 61cf2e6..d3b2d41 100644
--- a/src/trace_processor/rpc/rpc.h
+++ b/src/trace_processor/rpc/rpc.h
@@ -22,6 +22,7 @@
 #include <functional>
 #include <memory>
 #include <string>
+#include <utility>
 #include <vector>
 
 #include "perfetto/base/status.h"
@@ -81,8 +82,11 @@
   // with Wasm, where size_t = uint32_t.
   // (nullptr, 0) has the semantic of "close the channel" and is issued when an
   // unrecoverable wire-protocol framing error is detected.
-  using RpcResponseFunction = void (*)(const void* /*data*/, uint32_t /*len*/);
-  void SetRpcResponseFunction(RpcResponseFunction f) { rpc_response_fn_ = f; }
+  using RpcResponseFunction =
+      std::function<void(const void* /*data*/, uint32_t /*len*/)>;
+  void SetRpcResponseFunction(RpcResponseFunction f) {
+    rpc_response_fn_ = std::move(f);
+  }
 
   // 2. TraceProcessor legacy RPC endpoints.
   // The methods below are exposed for the old RPC interfaces, where each RPC
diff --git a/src/trace_processor/rpc/wasm_bridge.cc b/src/trace_processor/rpc/wasm_bridge.cc
index a9ba607..ca27abc 100644
--- a/src/trace_processor/rpc/wasm_bridge.cc
+++ b/src/trace_processor/rpc/wasm_bridge.cc
@@ -22,6 +22,8 @@
 namespace perfetto::trace_processor {
 
 namespace {
+using RpcResponseFn = void(const void*, uint32_t);
+
 Rpc* g_trace_processor_rpc;
 
 // The buffer used to pass the request arguments. The caller (JS) decides how
@@ -35,9 +37,9 @@
 extern "C" {
 
 // Returns the address of the allocated request buffer.
-uint8_t* EMSCRIPTEN_KEEPALIVE trace_processor_rpc_init(Rpc::RpcResponseFunction,
-                                                       uint32_t);
-uint8_t* trace_processor_rpc_init(Rpc::RpcResponseFunction resp_function,
+uint8_t* EMSCRIPTEN_KEEPALIVE
+trace_processor_rpc_init(RpcResponseFn* RpcResponseFn, uint32_t);
+uint8_t* trace_processor_rpc_init(RpcResponseFn* resp_function,
                                   uint32_t req_buffer_size) {
   g_trace_processor_rpc = new Rpc();
 
diff --git a/src/trace_redaction/BUILD.gn b/src/trace_redaction/BUILD.gn
index 8ff31a3..29c4a75 100644
--- a/src/trace_redaction/BUILD.gn
+++ b/src/trace_redaction/BUILD.gn
@@ -67,6 +67,8 @@
     "redact_sched_switch.h",
     "redact_task_newtask.cc",
     "redact_task_newtask.h",
+    "remap_scheduling_events.cc",
+    "remap_scheduling_events.h",
     "scrub_ftrace_events.cc",
     "scrub_ftrace_events.h",
     "scrub_process_stats.cc",
@@ -105,6 +107,7 @@
     "filter_task_rename_integrationtest.cc",
     "prune_package_list_integrationtest.cc",
     "redact_sched_switch_integrationtest.cc",
+    "remap_scheduling_events_integrationtest.cc",
     "scrub_ftrace_events_integrationtest.cc",
     "scrub_process_stats_integrationtest.cc",
     "scrub_process_trees_integrationtest.cc",
@@ -143,6 +146,7 @@
     "redact_process_free_unittest.cc",
     "redact_sched_switch_unittest.cc",
     "redact_task_newtask_unittest.cc",
+    "remap_scheduling_events_unittest.cc",
     "suspend_resume_unittest.cc",
   ]
   deps = [
diff --git a/src/trace_redaction/main.cc b/src/trace_redaction/main.cc
index 04cfa1e..051a424 100644
--- a/src/trace_redaction/main.cc
+++ b/src/trace_redaction/main.cc
@@ -32,6 +32,7 @@
 #include "src/trace_redaction/redact_process_free.h"
 #include "src/trace_redaction/redact_sched_switch.h"
 #include "src/trace_redaction/redact_task_newtask.h"
+#include "src/trace_redaction/remap_scheduling_events.h"
 #include "src/trace_redaction/scrub_ftrace_events.h"
 #include "src/trace_redaction/scrub_process_stats.h"
 #include "src/trace_redaction/scrub_process_trees.h"
@@ -88,6 +89,23 @@
   redact_ftrace_events
       ->emplace_back<RedactProcessFree::kFieldId, RedactProcessFree>();
 
+  // This set of transformations will change pids. This will break the
+  // connections between pids and the timeline (the synth threads are not in the
+  // timeline). If a transformation uses the timeline, it must be before this
+  // transformation.
+  auto* redact_sched_events = redactor.emplace_transform<RedactFtraceEvent>();
+  redact_sched_events->emplace_back<ThreadMergeRemapFtraceEventPid::kFieldId,
+                                    ThreadMergeRemapFtraceEventPid>();
+  redact_sched_events->emplace_back<ThreadMergeRemapSchedSwitchPid::kFieldId,
+                                    ThreadMergeRemapSchedSwitchPid>();
+  redact_sched_events->emplace_back<ThreadMergeRemapSchedWakingPid::kFieldId,
+                                    ThreadMergeRemapSchedWakingPid>();
+  redact_sched_events->emplace_back<
+      ThreadMergeDropField::kTaskNewtaskFieldNumber, ThreadMergeDropField>();
+  redact_sched_events
+      ->emplace_back<ThreadMergeDropField::kSchedProcessFreeFieldNumber,
+                     ThreadMergeDropField>();
+
   Context context;
   context.package_name = package_name;
 
diff --git a/src/trace_redaction/remap_scheduling_events.cc b/src/trace_redaction/remap_scheduling_events.cc
new file mode 100644
index 0000000..4817f19
--- /dev/null
+++ b/src/trace_redaction/remap_scheduling_events.cc
@@ -0,0 +1,261 @@
+/*
+ * Copyright (C) 2024 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_redaction/remap_scheduling_events.h"
+
+#include "src/trace_redaction/proto_util.h"
+
+#include "protos/perfetto/trace/ftrace/ftrace_event.pbzero.h"
+#include "protos/perfetto/trace/ftrace/ftrace_event_bundle.pbzero.h"
+#include "protos/perfetto/trace/ftrace/sched.pbzero.h"
+
+namespace perfetto::trace_redaction {
+
+namespace {
+int32_t RemapPid(const Context& context,
+                 uint64_t timestamp,
+                 uint32_t cpu,
+                 int32_t pid) {
+  PERFETTO_DCHECK(context.package_uid.value());
+  PERFETTO_DCHECK(cpu < context.synthetic_threads->tids.size());
+
+  auto slice = context.timeline->Search(timestamp, pid);
+
+  auto expected_uid = NormalizeUid(slice.uid);
+  auto actual_uid = NormalizeUid(context.package_uid.value());
+
+  return !pid || expected_uid == actual_uid
+             ? pid
+             : context.synthetic_threads->tids[cpu];
+}
+}  // namespace
+
+base::Status ThreadMergeRemapFtraceEventPid::Redact(
+    const Context& context,
+    const protos::pbzero::FtraceEventBundle::Decoder& bundle,
+    protozero::ProtoDecoder& event,
+    protos::pbzero::FtraceEvent* event_message) const {
+  if (!context.package_uid.has_value()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapFtraceEventPid: missing package uid");
+  }
+
+  if (!context.synthetic_threads.has_value()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapFtraceEventPid: missing synthetic threads");
+  }
+
+  // This should never happen. A bundle should have a cpu.
+  if (!bundle.has_cpu()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapFtraceEventPid: Invalid ftrace event, missing cpu.");
+  }
+
+  if (bundle.cpu() >= context.synthetic_threads->tids.size()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapFtraceEventPid: synthetic thread count");
+  }
+
+  auto timestamp =
+      event.FindField(protos::pbzero::FtraceEvent::kTimestampFieldNumber);
+
+  // This should never happen. An event should have a timestamp.
+  if (!timestamp.valid()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapFtraceEventPid: Invalid ftrace event, missing "
+        "timestamp.");
+  }
+
+  // This handler should only be called for the pid field.
+  auto pid = event.FindField(protos::pbzero::FtraceEvent::kPidFieldNumber);
+  PERFETTO_DCHECK(pid.valid());
+
+  // The event's pid is technically a uint, but we need it as a int.
+  auto new_pid =
+      RemapPid(context, timestamp.as_uint64(), bundle.cpu(), pid.as_int32());
+  event_message->set_pid(static_cast<uint32_t>(new_pid));
+
+  return base::OkStatus();
+}
+
+base::Status ThreadMergeRemapSchedSwitchPid::Redact(
+    const Context& context,
+    const protos::pbzero::FtraceEventBundle::Decoder& bundle,
+    protozero::ProtoDecoder& event,
+    protos::pbzero::FtraceEvent* event_message) const {
+  if (!context.package_uid.has_value()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedSwitchPid: missing package uid");
+  }
+
+  if (!context.synthetic_threads.has_value()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedSwitchPid: missing synthetic threads");
+  }
+
+  // This should never happen. A bundle should have a cpu.
+  if (!bundle.has_cpu()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedSwitchPid: Invalid ftrace event, missing cpu.");
+  }
+
+  if (bundle.cpu() >= context.synthetic_threads->tids.size()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedSwitchPid: synthetic thread count");
+  }
+
+  auto timestamp =
+      event.FindField(protos::pbzero::FtraceEvent::kTimestampFieldNumber);
+
+  // This should never happen. An event should have a timestamp.
+  if (!timestamp.valid()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedSwitchPid: Invalid ftrace event, missing "
+        "timestamp.");
+  }
+
+  // This handler should only be called for the sched switch field.
+  auto sched_switch =
+      event.FindField(protos::pbzero::FtraceEvent::kSchedSwitchFieldNumber);
+  PERFETTO_DCHECK(sched_switch.valid());
+
+  protozero::ProtoDecoder sched_switch_decoder(sched_switch.as_bytes());
+
+  auto old_prev_pid_field = sched_switch_decoder.FindField(
+      protos::pbzero::SchedSwitchFtraceEvent::kPrevPidFieldNumber);
+  auto old_next_pid_field = sched_switch_decoder.FindField(
+      protos::pbzero::SchedSwitchFtraceEvent::kNextPidFieldNumber);
+
+  if (!old_prev_pid_field.valid()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedSwitchPid: Invalid sched switch event, missing "
+        "prev pid");
+  }
+
+  if (!old_next_pid_field.valid()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedSwitchPid: Invalid sched switch event, missing "
+        "next pid");
+  }
+
+  auto new_prev_pid_field =
+      RemapPid(context, timestamp.as_uint64(), bundle.cpu(),
+               old_prev_pid_field.as_int32());
+  auto new_next_pid_field =
+      RemapPid(context, timestamp.as_uint64(), bundle.cpu(),
+               old_next_pid_field.as_int32());
+
+  auto* sched_switch_message = event_message->set_sched_switch();
+
+  for (auto f = sched_switch_decoder.ReadField(); f.valid();
+       f = sched_switch_decoder.ReadField()) {
+    switch (f.id()) {
+      case protos::pbzero::SchedSwitchFtraceEvent::kPrevPidFieldNumber:
+        sched_switch_message->set_prev_pid(new_prev_pid_field);
+        break;
+
+      case protos::pbzero::SchedSwitchFtraceEvent::kNextPidFieldNumber:
+        sched_switch_message->set_next_pid(new_next_pid_field);
+        break;
+
+      default:
+        proto_util::AppendField(f, sched_switch_message);
+        break;
+    }
+  }
+
+  return base::OkStatus();
+}
+
+base::Status ThreadMergeRemapSchedWakingPid::Redact(
+    const Context& context,
+    const protos::pbzero::FtraceEventBundle::Decoder& bundle,
+    protozero::ProtoDecoder& event,
+    protos::pbzero::FtraceEvent* event_message) const {
+  if (!context.package_uid.has_value()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedWakingPid: missing package uid");
+  }
+
+  if (!context.synthetic_threads.has_value()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedWakingPid: missing synthetic threads");
+  }
+
+  // This should never happen. A bundle should have a cpu.
+  if (!bundle.has_cpu()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedWakingPid: Invalid ftrace event, missing cpu.");
+  }
+
+  if (bundle.cpu() >= context.synthetic_threads->tids.size()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedWakingPid: synthetic thread count");
+  }
+
+  auto timestamp =
+      event.FindField(protos::pbzero::FtraceEvent::kTimestampFieldNumber);
+
+  // This should never happen. An event should have a timestamp.
+  if (!timestamp.valid()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedWakingPid: Invalid ftrace event, missing "
+        "timestamp.");
+  }
+
+  // This handler should only be called for the sched waking field.
+  auto sched_waking =
+      event.FindField(protos::pbzero::FtraceEvent::kSchedWakingFieldNumber);
+  PERFETTO_DCHECK(sched_waking.valid());
+
+  protozero::ProtoDecoder sched_waking_decoder(sched_waking.as_bytes());
+
+  auto old_pid = sched_waking_decoder.FindField(
+      protos::pbzero::SchedWakingFtraceEvent::kPidFieldNumber);
+
+  if (!old_pid.valid()) {
+    return base::ErrStatus(
+        "ThreadMergeRemapSchedWakingPid: Invalid sched waking event, missing "
+        "pid");
+  }
+
+  auto new_pid_field = RemapPid(context, timestamp.as_uint64(), bundle.cpu(),
+                                old_pid.as_int32());
+
+  auto* sched_waking_message = event_message->set_sched_waking();
+
+  for (auto f = sched_waking_decoder.ReadField(); f.valid();
+       f = sched_waking_decoder.ReadField()) {
+    if (f.id() == protos::pbzero::SchedWakingFtraceEvent::kPidFieldNumber) {
+      sched_waking_message->set_pid(new_pid_field);
+    } else {
+      proto_util::AppendField(f, sched_waking_message);
+    }
+  }
+
+  return base::OkStatus();
+}
+
+// By doing nothing, the field gets dropped.
+base::Status ThreadMergeDropField::Redact(
+    const Context&,
+    const protos::pbzero::FtraceEventBundle::Decoder&,
+    protozero::ProtoDecoder&,
+    protos::pbzero::FtraceEvent*) const {
+  return base::OkStatus();
+}
+
+}  // namespace perfetto::trace_redaction
diff --git a/src/trace_redaction/remap_scheduling_events.h b/src/trace_redaction/remap_scheduling_events.h
new file mode 100644
index 0000000..0160534
--- /dev/null
+++ b/src/trace_redaction/remap_scheduling_events.h
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2024 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_REDACTION_REMAP_SCHEDULING_EVENTS_H_
+#define SRC_TRACE_REDACTION_REMAP_SCHEDULING_EVENTS_H_
+
+#include "perfetto/protozero/proto_decoder.h"
+#include "src/trace_redaction/redact_ftrace_event.h"
+#include "src/trace_redaction/trace_redaction_framework.h"
+
+#include "protos/perfetto/trace/ftrace/ftrace_event.pbzero.h"
+
+namespace perfetto::trace_redaction {
+
+// Reads the Ftrace event's pid and replaces it with a synthetic thread id (if
+// necessary).
+class ThreadMergeRemapFtraceEventPid : public FtraceEventRedaction {
+ public:
+  static constexpr auto kFieldId = protos::pbzero::FtraceEvent::kPidFieldNumber;
+
+  base::Status Redact(
+      const Context& context,
+      const protos::pbzero::FtraceEventBundle::Decoder& bundle,
+      protozero::ProtoDecoder& event,
+      protos::pbzero::FtraceEvent* event_message) const override;
+};
+
+// Reads the sched switch pid and replaces it with a synthetic thread id (if
+// necessary).
+//
+//  event {
+//    timestamp: 6702093743539938
+//    pid: 0
+//    sched_switch {
+//      prev_comm: "swapper/7"
+//      prev_pid: 0
+//      prev_prio: 120
+//      prev_state: 0
+//      next_comm: "FMOD stream thr"
+//      next_pid: 7174
+//      next_prio: 104
+//    }
+//  }
+class ThreadMergeRemapSchedSwitchPid : public FtraceEventRedaction {
+ public:
+  static constexpr auto kFieldId =
+      protos::pbzero::FtraceEvent::kSchedSwitchFieldNumber;
+
+  base::Status Redact(
+      const Context& context,
+      const protos::pbzero::FtraceEventBundle::Decoder& bundle,
+      protozero::ProtoDecoder& event,
+      protos::pbzero::FtraceEvent* event_message) const override;
+};
+
+// Reads the sched waking pid and replaces it with a synthetic thread id (if
+// necessary).
+//
+//  event {
+//    timestamp: 6702093743527386
+//    pid: 0
+//    sched_waking {
+//      comm: "FMOD stream thr"
+//      pid: 7174
+//      prio: 104
+//      success: 1
+//      target_cpu: 7
+//    }
+//  }
+class ThreadMergeRemapSchedWakingPid : public FtraceEventRedaction {
+ public:
+  static constexpr auto kFieldId =
+      protos::pbzero::FtraceEvent::kSchedWakingFieldNumber;
+
+  base::Status Redact(
+      const Context& context,
+      const protos::pbzero::FtraceEventBundle::Decoder& bundle,
+      protozero::ProtoDecoder& event,
+      protos::pbzero::FtraceEvent* event_message) const override;
+};
+
+// Drop "new task" events because it's safe to assume that the threads always
+// exist.
+//
+//  event {
+//    timestamp: 6702094133317685
+//    pid: 6167
+//    task_newtask {
+//      pid: 7972                 <-- Pid being started
+//      comm: "adbd"
+//      clone_flags: 4001536
+//      oom_score_adj: -1000
+//    }
+//  }
+//
+// Drop "process free" events because it's safe to assume that the threads
+// always exist.
+//
+//  event {
+//    timestamp: 6702094703942898
+//    pid: 10
+//    sched_process_free {
+//      comm: "shell svc 7973"
+//      pid: 7974                 <-- Pid being freed
+//      prio: 120
+//    }
+//  }
+class ThreadMergeDropField : public FtraceEventRedaction {
+ public:
+  static constexpr auto kTaskNewtaskFieldNumber =
+      protos::pbzero::FtraceEvent::kTaskNewtaskFieldNumber;
+  static constexpr auto kSchedProcessFreeFieldNumber =
+      protos::pbzero::FtraceEvent::kSchedProcessFreeFieldNumber;
+
+  base::Status Redact(
+      const Context& context,
+      const protos::pbzero::FtraceEventBundle::Decoder& bundle,
+      protozero::ProtoDecoder& event,
+      protos::pbzero::FtraceEvent* event_message) const override;
+};
+
+}  // namespace perfetto::trace_redaction
+
+#endif  // SRC_TRACE_REDACTION_REMAP_SCHEDULING_EVENTS_H_
diff --git a/src/trace_redaction/remap_scheduling_events_integrationtest.cc b/src/trace_redaction/remap_scheduling_events_integrationtest.cc
new file mode 100644
index 0000000..2f68c95
--- /dev/null
+++ b/src/trace_redaction/remap_scheduling_events_integrationtest.cc
@@ -0,0 +1,292 @@
+/*
+ * Copyright (C) 2024 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/base/test/status_matchers.h"
+#include "src/trace_redaction/collect_system_info.h"
+#include "src/trace_redaction/collect_timeline_events.h"
+#include "src/trace_redaction/find_package_uid.h"
+#include "src/trace_redaction/redact_ftrace_event.h"
+#include "src/trace_redaction/remap_scheduling_events.h"
+#include "src/trace_redaction/trace_redaction_integration_fixture.h"
+#include "test/gtest_and_gmock.h"
+
+#include "protos/perfetto/trace/ftrace/sched.pbzero.h"
+#include "protos/perfetto/trace/ftrace/task.pbzero.h"
+
+namespace perfetto::trace_redaction {
+
+// Runs ThreadMergeRemapFtraceEventPid, ThreadMergeRemapSchedSwitchPid,
+// ThreadMergeRemapSchedWakingPid, and ThreadMergeDropField to replace pids with
+// synthetic pids (for all threads outside of the target package);
+class RemapSchedulingEventsIntegrationTest
+    : public testing::Test,
+      protected TraceRedactionIntegrationFixure {
+ public:
+  static constexpr auto kPackageName =
+      "com.Unity.com.unity.multiplayer.samples.coop";
+  static constexpr uint64_t kPackageId = 10252;
+  static constexpr int32_t kPid = 7105;
+
+  // Threads belonging to pid 7105. Collected using trace processors.
+  static constexpr auto kTids = {
+      0,  // pid 0 will always be included because CPU idle uses it.
+      7105, 7111, 7112, 7113, 7114, 7115, 7116, 7117, 7118, 7119, 7120,
+      7124, 7125, 7127, 7129, 7130, 7131, 7132, 7133, 7134, 7135, 7136,
+      7137, 7139, 7141, 7142, 7143, 7144, 7145, 7146, 7147, 7148, 7149,
+      7150, 7151, 7152, 7153, 7154, 7155, 7156, 7157, 7158, 7159, 7160,
+      7161, 7162, 7163, 7164, 7165, 7166, 7167, 7171, 7172, 7174, 7178,
+      7180, 7184, 7200, 7945, 7946, 7947, 7948, 7950, 7969,
+  };
+
+ protected:
+  void SetUp() override {
+    trace_redactor()->emplace_collect<FindPackageUid>();
+
+    // In order to remap threads, we need to have synth threads.
+    trace_redactor()->emplace_collect<CollectSystemInfo>();
+    trace_redactor()->emplace_build<BuildSyntheticThreads>();
+
+    // Timeline information is needed to know if a pid belongs to a package.
+    trace_redactor()->emplace_collect<CollectTimelineEvents>();
+
+    auto* redactions = trace_redactor()->emplace_transform<RedactFtraceEvent>();
+    redactions->emplace_back<ThreadMergeRemapFtraceEventPid::kFieldId,
+                             ThreadMergeRemapFtraceEventPid>();
+    redactions->emplace_back<ThreadMergeRemapSchedSwitchPid::kFieldId,
+                             ThreadMergeRemapSchedSwitchPid>();
+    redactions->emplace_back<ThreadMergeRemapSchedWakingPid::kFieldId,
+                             ThreadMergeRemapSchedWakingPid>();
+    redactions->emplace_back<ThreadMergeDropField::kSchedProcessFreeFieldNumber,
+                             ThreadMergeDropField>();
+    redactions->emplace_back<ThreadMergeDropField::kTaskNewtaskFieldNumber,
+                             ThreadMergeDropField>();
+
+    context()->package_name = kPackageName;
+  }
+
+  struct Index {
+    // List of FtraceEvent
+    std::vector<protozero::ConstBytes> events;
+
+    // List of SchedSwitchFtraceEvent
+    std::vector<protozero::ConstBytes> events_sched_switch;
+
+    // List of SchedWakingFtraceEvent
+    std::vector<protozero::ConstBytes> events_sched_waking;
+
+    // List of SchedProcessFreeFtraceEvent
+    std::vector<protozero::ConstBytes> events_sched_process_free;
+
+    // List of TaskNewtaskFtraceEvent
+    std::vector<protozero::ConstBytes> events_task_newtask;
+  };
+
+  void UpdateFtraceIndex(protozero::ConstBytes bytes, Index* index) {
+    protos::pbzero::FtraceEventBundle::Decoder bundle(bytes);
+
+    for (auto event = bundle.event(); event; ++event) {
+      index->events.push_back(event->as_bytes());
+
+      // protos::pbzero::FtraceEvent
+      protozero::ProtoDecoder ftrace_event(event->as_bytes());
+
+      auto sched_switch = ftrace_event.FindField(
+          protos::pbzero::FtraceEvent::kSchedSwitchFieldNumber);
+      if (sched_switch.valid()) {
+        index->events_sched_switch.push_back(sched_switch.as_bytes());
+      }
+
+      auto sched_waking = ftrace_event.FindField(
+          protos::pbzero::FtraceEvent::kSchedWakingFieldNumber);
+      if (sched_waking.valid()) {
+        index->events_sched_waking.push_back(sched_waking.as_bytes());
+      }
+
+      auto sched_process_free = ftrace_event.FindField(
+          protos::pbzero::FtraceEvent::kSchedProcessFreeFieldNumber);
+      if (sched_process_free.valid()) {
+        index->events_sched_process_free.push_back(
+            sched_process_free.as_bytes());
+      }
+
+      auto task_newtask = ftrace_event.FindField(
+          protos::pbzero::FtraceEvent::kTaskNewtaskFieldNumber);
+      if (task_newtask.valid()) {
+        index->events_task_newtask.push_back(task_newtask.as_bytes());
+      }
+    }
+  }
+
+  // Bytes should be TracePacket
+  Index CreateFtraceIndex(const std::string& bytes) {
+    Index index;
+
+    protozero::ProtoDecoder packet_decoder(bytes);
+
+    for (auto packet = packet_decoder.ReadField(); packet.valid();
+         packet = packet_decoder.ReadField()) {
+      auto events = packet_decoder.FindField(
+          protos::pbzero::TracePacket::kFtraceEventsFieldNumber);
+
+      if (events.valid()) {
+        UpdateFtraceIndex(events.as_bytes(), &index);
+      }
+    }
+
+    return index;
+  }
+
+  base::StatusOr<std::string> LoadAndRedactTrace() {
+    auto source = LoadOriginal();
+
+    if (!source.ok()) {
+      return source.status();
+    }
+
+    auto redact = Redact();
+
+    if (!redact.ok()) {
+      return redact;
+    }
+
+    // Double-check the package id with the one from trace processor. If this
+    // was wrong and this check was missing, finding the problem would be much
+    // harder.
+    if (!context()->package_uid.has_value()) {
+      return base::ErrStatus("Missing package uid.");
+    }
+
+    if (context()->package_uid.value() != kPackageId) {
+      return base::ErrStatus("Unexpected package uid found.");
+    }
+
+    auto redacted = LoadRedacted();
+
+    if (redacted.ok()) {
+      return redacted;
+    }
+
+    // System info is used to initialize the synth threads. If these are wrong,
+    // then the synth threads will be wrong.
+    if (!context()->system_info.has_value()) {
+      return base::ErrStatus("Missing system info.");
+    }
+
+    if (context()->system_info->last_cpu() != 7u) {
+      return base::ErrStatus("Unexpected cpu count.");
+    }
+
+    // The synth threads should have been initialized. They will be used here to
+    // verify which threads exist in the redacted trace.
+    if (!context()->synthetic_threads.has_value()) {
+      return base::ErrStatus("Missing synthetic threads.");
+    }
+
+    if (context()->synthetic_threads->tids.size() != 8u) {
+      return base::ErrStatus("Unexpected synthentic thread count.");
+    }
+
+    return redacted;
+  }
+
+  // Should be called after redaction since it requires data from the context.
+  std::unordered_set<int32_t> CopyAllowedTids(const Context& context) const {
+    std::unordered_set<int32_t> tids(kTids.begin(), kTids.end());
+
+    tids.insert(context.synthetic_threads->tgid);
+    tids.insert(context.synthetic_threads->tids.begin(),
+                context.synthetic_threads->tids.end());
+
+    return tids;
+  }
+
+ private:
+  std::unordered_set<int32_t> allowed_tids_;
+};
+
+TEST_F(RemapSchedulingEventsIntegrationTest, FilterFtraceEventPid) {
+  auto redacted = LoadAndRedactTrace();
+  ASSERT_OK(redacted);
+
+  auto allowlist = CopyAllowedTids(*context());
+
+  auto index = CreateFtraceIndex(*redacted);
+
+  for (const auto& event : index.events) {
+    protos::pbzero::FtraceEvent::Decoder decoder(event);
+    auto pid = static_cast<int32_t>(decoder.pid());
+    ASSERT_TRUE(allowlist.count(pid));
+  }
+}
+
+TEST_F(RemapSchedulingEventsIntegrationTest, FiltersSchedSwitch) {
+  auto redacted = LoadAndRedactTrace();
+  ASSERT_OK(redacted);
+
+  auto allowlist = CopyAllowedTids(*context());
+
+  auto index = CreateFtraceIndex(*redacted);
+
+  for (const auto& event : index.events_sched_switch) {
+    protos::pbzero::SchedSwitchFtraceEvent::Decoder decoder(event);
+    ASSERT_TRUE(allowlist.count(decoder.prev_pid()));
+    ASSERT_TRUE(allowlist.count(decoder.next_pid()));
+  }
+}
+
+TEST_F(RemapSchedulingEventsIntegrationTest, FiltersSchedWaking) {
+  auto redacted = LoadAndRedactTrace();
+  ASSERT_OK(redacted);
+
+  auto allowlist = CopyAllowedTids(*context());
+
+  auto index = CreateFtraceIndex(*redacted);
+
+  for (const auto& event : index.events_sched_waking) {
+    protos::pbzero::SchedWakingFtraceEvent::Decoder decoder(event);
+    ASSERT_TRUE(allowlist.count(decoder.pid()));
+  }
+}
+
+TEST_F(RemapSchedulingEventsIntegrationTest, FiltersProcessFree) {
+  auto redacted = LoadAndRedactTrace();
+  ASSERT_OK(redacted);
+
+  auto allowlist = CopyAllowedTids(*context());
+
+  auto index = CreateFtraceIndex(*redacted);
+
+  for (const auto& event : index.events_sched_process_free) {
+    protos::pbzero::SchedProcessFreeFtraceEvent::Decoder decoder(event);
+    ASSERT_TRUE(allowlist.count(decoder.pid()));
+  }
+}
+
+TEST_F(RemapSchedulingEventsIntegrationTest, FiltersNewTask) {
+  auto redacted = LoadAndRedactTrace();
+  ASSERT_OK(redacted);
+
+  auto allowlist = CopyAllowedTids(*context());
+
+  auto index = CreateFtraceIndex(*redacted);
+
+  for (const auto& event : index.events_task_newtask) {
+    protos::pbzero::TaskNewtaskFtraceEvent::Decoder decoder(event);
+    ASSERT_TRUE(allowlist.count(decoder.pid()));
+  }
+}
+
+}  // namespace perfetto::trace_redaction
diff --git a/src/trace_redaction/remap_scheduling_events_unittest.cc b/src/trace_redaction/remap_scheduling_events_unittest.cc
new file mode 100644
index 0000000..5cdc753
--- /dev/null
+++ b/src/trace_redaction/remap_scheduling_events_unittest.cc
@@ -0,0 +1,441 @@
+/*
+ * Copyright (C) 2024 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_redaction/remap_scheduling_events.h"
+
+#include "perfetto/protozero/scattered_heap_buffer.h"
+#include "src/base/test/status_matchers.h"
+#include "src/trace_redaction/trace_redaction_framework.h"
+#include "test/gtest_and_gmock.h"
+
+#include "protos/perfetto/trace/ftrace/ftrace_event.gen.h"
+#include "protos/perfetto/trace/ftrace/ftrace_event_bundle.gen.h"
+#include "protos/perfetto/trace/ftrace/sched.gen.h"
+
+namespace perfetto::trace_redaction {
+
+template <class T>
+class ThreadMergeTest {
+ protected:
+  struct Process {
+    uint64_t uid;
+    int32_t ppid;
+    int32_t pid;
+  };
+
+  base::Status Redact(protos::pbzero::FtraceEvent* event_message) {
+    T redact;
+
+    auto bundle_str = bundle_.SerializeAsString();
+    protos::pbzero::FtraceEventBundle::Decoder bundle_decoder(bundle_str);
+
+    auto event_str = bundle_.event().back().SerializeAsString();
+    protos::pbzero::FtraceEvent::Decoder event_decoder(event_str);
+
+    return redact.Redact(context_, bundle_decoder, event_decoder,
+                         event_message);
+  }
+
+  Context context_;
+  protos::gen::FtraceEventBundle bundle_;
+};
+
+// All ftrace events have a timestamp and a pid. This test focuses on the
+// event's pid value. When that pid doesn't belong to the target package, it
+// should be replaced with a synthetic thread id.
+//
+//  event {
+//    timestamp: 6702093743539938
+//    pid: 0
+//    sched_switch { ... }
+//  }
+class ThreadMergeRemapFtraceEventPidTest
+    : public testing::Test,
+      protected ThreadMergeTest<ThreadMergeRemapFtraceEventPid> {
+ protected:
+  static constexpr uint32_t kCpu = 3;
+
+  static constexpr auto kTimestamp = 123456789;
+
+  // This process will be connected to the target package.
+  static constexpr Process kProcess = {12, 5, 7};
+
+  // This process will not be connected to the target package.
+  static constexpr Process kOtherProcess = {120, 50, 70};
+
+  void SetUp() override {
+    bundle_.add_event();
+
+    context_.package_uid = kProcess.uid;
+
+    context_.timeline = std::make_unique<ProcessThreadTimeline>();
+    context_.timeline->Append(ProcessThreadTimeline::Event::Open(
+        0, kProcess.pid, kProcess.ppid, kProcess.uid));
+    context_.timeline->Append(ProcessThreadTimeline::Event::Open(
+        0, kOtherProcess.pid, kOtherProcess.ppid, kOtherProcess.uid));
+    context_.timeline->Sort();
+
+    // Because kCpu is 3, it means that there are four CPUs (id 0, id 1, ...).
+    context_.synthetic_threads.emplace();
+    context_.synthetic_threads->tids.assign({100, 101, 102, 103});
+  }
+};
+
+// This should never happen, a bundle should always have a cpu. If it doesn't
+// have a CPU, the event field should be dropped (safest option).
+//
+// TODO(vaage): This will create an invalid trace. It can also leak information
+// if other primitives don't strip the remaining information. To be safe, these
+// cases should be replaced with errors.
+TEST_F(ThreadMergeRemapFtraceEventPidTest, MissingCpuReturnsError) {
+  // Do not call set_cpu(uint32_t value). There should be no cpu for this case.
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_FALSE(Redact(event_message.get()).ok());
+}
+
+// This should never happen, an event should always have a timestamp. If it
+// doesn't have a timestamp, the event field should be dropped (safest option).
+//
+// TODO(vaage): This will create an invalid trace. It can also leak information
+// if other primitives don't strip the remaining information. To be safe, these
+// cases should be replaced with errors.
+TEST_F(ThreadMergeRemapFtraceEventPidTest, MissingTimestampReturnsError) {
+  bundle_.set_cpu(kCpu);
+  // Do not call set_timestamp(uint64_t value). There should be no timestamp for
+  // this case.
+  bundle_.mutable_event()->back().set_pid(kProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_FALSE(Redact(event_message.get()).ok());
+}
+
+TEST_F(ThreadMergeRemapFtraceEventPidTest, NoopWhenPidIsInPackage) {
+  bundle_.set_cpu(kCpu);
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_OK(Redact(event_message.get()));
+
+  protos::gen::FtraceEvent event;
+  event.ParseFromString(event_message.SerializeAsString());
+
+  ASSERT_TRUE(event.has_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.pid()), kProcess.pid);
+}
+
+TEST_F(ThreadMergeRemapFtraceEventPidTest, ChangesPidWhenPidIsOutsidePackage) {
+  bundle_.set_cpu(kCpu);  // The CPU is used to select the pid.
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kOtherProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_OK(Redact(event_message.get()));
+
+  protos::gen::FtraceEvent event;
+  event.ParseFromString(event_message.SerializeAsString());
+
+  ASSERT_TRUE(event.has_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.pid()),
+            context_.synthetic_threads->tids[kCpu]);
+}
+
+// When creating a sched_switch event, the event pid and the previous pid should
+// be the same pid.
+//
+//  event {
+//    timestamp: 6702093743539938
+//    pid: 0
+//    sched_switch {
+//      prev_comm: "swapper/7"
+//      prev_pid: 0
+//      prev_prio: 120
+//      prev_state: 0
+//      next_comm: "FMOD stream thr"
+//      next_pid: 7174
+//      next_prio: 104
+//    }
+//  }
+class ThreadMergeRemapSchedSwitchPidTest
+    : public testing::Test,
+      protected ThreadMergeTest<ThreadMergeRemapSchedSwitchPid> {
+ protected:
+  static constexpr uint32_t kCpu = 3;
+
+  static constexpr auto kTimestamp = 123456789;
+
+  // This process will be connected to the target package.
+  static constexpr Process kPrevProcess = {12, 5, 7};
+  static constexpr Process kNextProcess = {12, 5, 8};
+
+  // This process will not be connected to the target package.
+  static constexpr Process kOtherProcess = {120, 50, 70};
+
+  void SetUp() override {
+    bundle_.add_event();
+
+    context_.package_uid = kPrevProcess.uid;
+
+    context_.timeline = std::make_unique<ProcessThreadTimeline>();
+    context_.timeline->Append(ProcessThreadTimeline::Event::Open(
+        0, kPrevProcess.pid, kPrevProcess.ppid, kPrevProcess.uid));
+    context_.timeline->Append(ProcessThreadTimeline::Event::Open(
+        0, kNextProcess.pid, kNextProcess.ppid, kNextProcess.uid));
+    context_.timeline->Append(ProcessThreadTimeline::Event::Open(
+        0, kOtherProcess.pid, kOtherProcess.ppid, kOtherProcess.uid));
+
+    context_.timeline->Sort();
+
+    // Because kCpu is 3, it means that there are four CPUs (id 0, id 1, ...).
+    context_.synthetic_threads.emplace();
+    context_.synthetic_threads->tids.assign({100, 101, 102, 103});
+  }
+};
+
+// This should never happen, a bundle should always have a cpu. If it doesn't
+// have a CPU, the event field should be dropped (safest option).
+//
+// TODO(vaage): This will create an invalid trace. It can also leak information
+// if other primitives don't strip the remaining information. To be safe, these
+// cases should be replaced with errors.
+TEST_F(ThreadMergeRemapSchedSwitchPidTest, MissingCpuReturnsError) {
+  // Do not call set_cpu(uint32_t value). There should be no cpu for this case.
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kPrevProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_FALSE(Redact(event_message.get()).ok());
+}
+
+// This should never happen, an event should always have a timestamp. If it
+// doesn't have a timestamp, the event field should be dropped (safest option).
+//
+// TODO(vaage): This will create an invalid trace. It can also leak information
+// if other primitives don't strip the remaining information. To be safe, these
+// cases should be replaced with errors.
+TEST_F(ThreadMergeRemapSchedSwitchPidTest, MissingTimestampReturnsError) {
+  bundle_.set_cpu(kCpu);
+  // Do not call set_timestamp(uint64_t value). There should be no timestamp for
+  // this case.
+  bundle_.mutable_event()->back().set_pid(kPrevProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_FALSE(Redact(event_message.get()).ok());
+}
+
+TEST_F(ThreadMergeRemapSchedSwitchPidTest, NoopWhenPidIsInPackage) {
+  bundle_.set_cpu(kCpu);
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kPrevProcess.pid);
+
+  auto* sched_switch = bundle_.mutable_event()->back().mutable_sched_switch();
+  sched_switch->set_prev_pid(kPrevProcess.pid);
+  sched_switch->set_next_pid(kNextProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_OK(Redact(event_message.get()));
+
+  protos::gen::FtraceEvent event;
+  event.ParseFromString(event_message.SerializeAsString());
+
+  ASSERT_TRUE(event.has_sched_switch());
+
+  ASSERT_TRUE(event.sched_switch().has_prev_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.sched_switch().prev_pid()),
+            kPrevProcess.pid);
+
+  ASSERT_TRUE(event.sched_switch().has_next_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.sched_switch().next_pid()),
+            kNextProcess.pid);
+}
+
+TEST_F(ThreadMergeRemapSchedSwitchPidTest,
+       ChangesPrevPidWhenPidIsOutsidePackage) {
+  bundle_.set_cpu(kCpu);
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kPrevProcess.pid);
+
+  auto* sched_switch = bundle_.mutable_event()->back().mutable_sched_switch();
+  sched_switch->set_prev_pid(kOtherProcess.pid);
+  sched_switch->set_next_pid(kNextProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_OK(Redact(event_message.get()));
+
+  protos::gen::FtraceEvent event;
+  event.ParseFromString(event_message.SerializeAsString());
+
+  ASSERT_TRUE(event.has_sched_switch());
+
+  ASSERT_TRUE(event.sched_switch().has_prev_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.sched_switch().prev_pid()),
+            context_.synthetic_threads->tids[kCpu]);
+
+  ASSERT_TRUE(event.sched_switch().has_next_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.sched_switch().next_pid()),
+            kNextProcess.pid);
+}
+
+TEST_F(ThreadMergeRemapSchedSwitchPidTest,
+       ChangesNextPidWhenPidIsOutsidePackage) {
+  bundle_.set_cpu(kCpu);
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kPrevProcess.pid);
+
+  auto* sched_switch = bundle_.mutable_event()->back().mutable_sched_switch();
+  sched_switch->set_prev_pid(kPrevProcess.pid);
+  sched_switch->set_next_pid(kOtherProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_OK(Redact(event_message.get()));
+
+  protos::gen::FtraceEvent event;
+  event.ParseFromString(event_message.SerializeAsString());
+
+  ASSERT_TRUE(event.has_sched_switch());
+
+  ASSERT_TRUE(event.sched_switch().has_prev_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.sched_switch().prev_pid()),
+            kPrevProcess.pid);
+
+  ASSERT_TRUE(event.sched_switch().has_next_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.sched_switch().next_pid()),
+            context_.synthetic_threads->tids[kCpu]);
+}
+
+//  event {
+//    timestamp: 6702093743527386
+//    pid: 0
+//    sched_waking {
+//      comm: "FMOD stream thr"
+//      pid: 7174
+//      prio: 104
+//      success: 1
+//      target_cpu: 7
+//    }
+//  }
+class ThreadMergeRemapSchedWakingPidTest
+    : public testing::Test,
+      protected ThreadMergeTest<ThreadMergeRemapSchedWakingPid> {
+ protected:
+  static constexpr uint32_t kCpu = 3;
+
+  static constexpr auto kTimestamp = 123456789;
+
+  // This process will be connected to the target package.
+  static constexpr Process kWakerProcess = {12, 5, 7};
+  static constexpr Process kWakeTarget = {12, 5, 8};
+
+  // This process will not be connected to the target package.
+  static constexpr Process kOtherProcess = {120, 50, 70};
+
+  void SetUp() override {
+    bundle_.add_event();
+
+    context_.package_uid = kWakerProcess.uid;
+
+    context_.timeline = std::make_unique<ProcessThreadTimeline>();
+    context_.timeline->Append(ProcessThreadTimeline::Event::Open(
+        0, kWakerProcess.pid, kWakerProcess.ppid, kWakerProcess.uid));
+    context_.timeline->Append(ProcessThreadTimeline::Event::Open(
+        0, kWakeTarget.pid, kWakeTarget.ppid, kWakeTarget.uid));
+    context_.timeline->Append(ProcessThreadTimeline::Event::Open(
+        0, kOtherProcess.pid, kOtherProcess.ppid, kOtherProcess.uid));
+
+    context_.timeline->Sort();
+
+    // Because kCpu is 3, it means that there are four CPUs (id 0, id 1, ...).
+    context_.synthetic_threads.emplace();
+    context_.synthetic_threads->tids.assign({100, 101, 102, 103});
+  }
+};
+
+// This should never happen, a bundle should always have a cpu. If it doesn't
+// have a CPU, the event field should be dropped (safest option).
+//
+// TODO(vaage): This will create an invalid trace. It can also leak information
+// if other primitives don't strip the remaining information. To be safe, these
+// cases should be replaced with errors.
+TEST_F(ThreadMergeRemapSchedWakingPidTest, MissingCpuReturnsError) {
+  // Do not call set_cpu(uint32_t value). There should be no cpu for this case.
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kWakerProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_FALSE(Redact(event_message.get()).ok());
+}
+
+// This should never happen, an event should always have a timestamp. If it
+// doesn't have a timestamp, the event field should be dropped (safest option).
+//
+// TODO(vaage): This will create an invalid trace. It can also leak information
+// if other primitives don't strip the remaining information. To be safe, these
+// cases should be replaced with errors.
+TEST_F(ThreadMergeRemapSchedWakingPidTest, MissingTimestampReturnsError) {
+  bundle_.set_cpu(kCpu);
+  // Do not call set_timestamp(uint64_t value). There should be no timestamp for
+  // this case.
+  bundle_.mutable_event()->back().set_pid(kWakerProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_FALSE(Redact(event_message.get()).ok());
+}
+
+TEST_F(ThreadMergeRemapSchedWakingPidTest, NoopWhenPidIsInPackage) {
+  bundle_.set_cpu(kCpu);
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kWakerProcess.pid);
+
+  auto* sched_waking = bundle_.mutable_event()->back().mutable_sched_waking();
+  sched_waking->set_pid(kWakeTarget.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_OK(Redact(event_message.get()));
+
+  protos::gen::FtraceEvent event;
+  event.ParseFromString(event_message.SerializeAsString());
+
+  ASSERT_TRUE(event.has_sched_waking());
+
+  ASSERT_TRUE(event.sched_waking().has_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.sched_waking().pid()), kWakeTarget.pid);
+}
+
+TEST_F(ThreadMergeRemapSchedWakingPidTest, ChangesPidWhenPidIsOutsidePackage) {
+  bundle_.set_cpu(kCpu);
+  bundle_.mutable_event()->back().set_timestamp(kTimestamp);
+  bundle_.mutable_event()->back().set_pid(kWakerProcess.pid);
+
+  auto* sched_switch = bundle_.mutable_event()->back().mutable_sched_waking();
+  sched_switch->set_pid(kOtherProcess.pid);
+
+  protozero::HeapBuffered<protos::pbzero::FtraceEvent> event_message;
+  ASSERT_OK(Redact(event_message.get()));
+
+  protos::gen::FtraceEvent event;
+  event.ParseFromString(event_message.SerializeAsString());
+
+  ASSERT_TRUE(event.has_sched_waking());
+
+  ASSERT_TRUE(event.sched_waking().has_pid());
+  ASSERT_EQ(static_cast<int32_t>(event.sched_waking().pid()),
+            context_.synthetic_threads->tids[kCpu]);
+}
+
+}  // namespace perfetto::trace_redaction
diff --git a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
index 81c36d8..ea62427 100644
--- a/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_missing_track_names_load.png.sha256
@@ -1 +1 @@
-ff45b08442a6f59a4768f3ad697cffb43d19889f1f6523d5ed4ef83aff944864
\ No newline at end of file
+8b878dd1d284fda4558de3f184d078293546c09a1438e14183161eb6040cee26
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
index 4d7b25a..e6a988a 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_expand_browser_proc.png.sha256
@@ -1 +1 @@
-161c05f6bae9b0e2142e73e83f3bcf7a9c689c58411db9e345a63ba882605557
\ No newline at end of file
+d3e7cdfbb83dd4e1b674c5d7b90554fd915dab8747f3981a2b6dbb1fff955f61
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256 b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
index 2d48761..1be3a54 100644
--- a/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
+++ b/test/data/ui-screenshots/ui-chrome_rendering_desktop_load.png.sha256
@@ -1 +1 @@
-c0bf63de1c6c738fb994d64e5f2f7c5c0e4315f8627fc5fd68f5b906acbb814d
\ No newline at end of file
+e1d34e03cd7540d476eb93e159a785ea18a80083b3410898a967a56f9d7af5f8
\ No newline at end of file
diff --git a/test/trace_processor/diff_tests/stdlib/android/android_battery_stats_event_slices.out b/test/trace_processor/diff_tests/stdlib/android/android_battery_stats_event_slices.out
index 82cd36c..fc22476 100644
--- a/test/trace_processor/diff_tests/stdlib/android/android_battery_stats_event_slices.out
+++ b/test/trace_processor/diff_tests/stdlib/android/android_battery_stats_event_slices.out
@@ -1,4 +1,4 @@
-"ts","dur","track_name","str_value","int_value"
-1000,8000,"battery_stats.top","mail",123
-3000,-1,"battery_stats.job","mail_job",456
-1000,3000,"battery_stats.job","video_job",789
+"ts","dur","safe_dur","track_name","str_value","int_value"
+1000,8000,8000,"battery_stats.top","mail",123
+3000,-1,6000,"battery_stats.job","mail_job",456
+1000,3000,3000,"battery_stats.job","video_job",789
diff --git a/test/trace_processor/diff_tests/stdlib/android/tests.py b/test/trace_processor/diff_tests/stdlib/android/tests.py
index 9542f22..59a8877 100644
--- a/test/trace_processor/diff_tests/stdlib/android/tests.py
+++ b/test/trace_processor/diff_tests/stdlib/android/tests.py
@@ -106,10 +106,10 @@
         ORDER BY ts, track_name;
         """,
         out=Csv("""
-        "ts","dur","track_name","value","value_name"
-        1000,-1,"battery_stats.audio",1,"active"
-        1000,3000,"battery_stats.data_conn",13,"4G (LTE)"
-        4000,-1,"battery_stats.data_conn",20,"5G (NR)"
+        "ts","dur","safe_dur","track_name","value","value_name"
+        1000,-1,3000,"battery_stats.audio",1,"active"
+        1000,3000,3000,"battery_stats.data_conn",13,"4G (LTE)"
+        4000,-1,0,"battery_stats.data_conn",20,"5G (NR)"
         """))
 
   def test_anrs(self):
diff --git a/test/trace_processor/diff_tests/syntax/table_tests.py b/test/trace_processor/diff_tests/syntax/table_tests.py
index 1a3bbb3..7de63e9 100644
--- a/test/trace_processor/diff_tests/syntax/table_tests.py
+++ b/test/trace_processor/diff_tests/syntax/table_tests.py
@@ -156,16 +156,16 @@
         trace=DataPath('example_android_trace_30s.pb'),
         query="""
         WITH trivial_count AS (
-          SELECT DISTINCT name AS c FROM slice
+          SELECT DISTINCT name FROM slice
         ),
         few_results AS (
-          SELECT DISTINCT depth AS c FROM slice
+          SELECT DISTINCT depth FROM slice
         ),
         simple_nullable AS (
-          SELECT DISTINCT parent_id AS c FROM slice
+          SELECT DISTINCT parent_id FROM slice
         ),
         selector AS (
-          SELECT DISTINCT cpu AS c FROM ftrace_event
+          SELECT DISTINCT cpu FROM ftrace_event
         )
         SELECT
           (SELECT COUNT(*) FROM trivial_count) AS name,
@@ -251,3 +251,49 @@
         8,80
         9,90
         """))
+
+  def test_max(self):
+    return DiffTestBlueprint(
+        trace=DataPath('example_android_trace_30s.pb'),
+        query="""
+        CREATE PERFETTO MACRO max(col ColumnName)
+        RETURNS TableOrSubquery AS (
+          SELECT id, $col
+          FROM slice
+          ORDER BY $col DESC
+          LIMIT 1
+        );
+
+        SELECT
+          (SELECT id FROM max!(id)) AS id,
+          (SELECT id FROM max!(dur)) AS numeric,
+          (SELECT id FROM max!(name)) AS string,
+          (SELECT id FROM max!(parent_id)) AS nullable;
+        """,
+        out=Csv("""
+        "id","numeric","string","nullable"
+        20745,2698,148,20729
+        """))
+
+  def test_min(self):
+    return DiffTestBlueprint(
+        trace=DataPath('example_android_trace_30s.pb'),
+        query="""
+        CREATE PERFETTO MACRO min(col ColumnName)
+        RETURNS TableOrSubquery AS (
+          SELECT id, $col
+          FROM slice
+          ORDER BY $col ASC
+          LIMIT 1
+        );
+
+        SELECT
+          (SELECT id FROM min!(id)) AS id,
+          (SELECT id FROM min!(dur)) AS numeric,
+          (SELECT id FROM min!(name)) AS string,
+          (SELECT id FROM min!(parent_id)) AS nullable;
+        """,
+        out=Csv("""
+        "id","numeric","string","nullable"
+        0,3111,460,0
+        """))
diff --git a/tools/gen_amalgamated_python_tools b/tools/gen_amalgamated_python_tools
index 4b21069..e7ea967 100755
--- a/tools/gen_amalgamated_python_tools
+++ b/tools/gen_amalgamated_python_tools
@@ -27,6 +27,7 @@
     'python/tools/trace_processor.py': 'tools/trace_processor',
     'python/tools/cpu_profile.py': 'tools/cpu_profile',
     'python/tools/heap_profile.py': 'tools/heap_profile',
+    'python/tools/install_test_reporter_app.py': 'tools/install_test_reporter_app',
 }
 
 
diff --git a/tools/install_test_reporter_app b/tools/install_test_reporter_app
index a2582ef..3a2d046 100755
--- a/tools/install_test_reporter_app
+++ b/tools/install_test_reporter_app
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-# Copyright (C) 2023 The Android Open Source Project
+# Copyright (C) 2024 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.
@@ -13,6 +13,10 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+# DO NOT EDIT. Auto-generated by tools/gen_amalgamated_python_tools
+# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
 import argparse
 import os
 import re
@@ -20,10 +24,144 @@
 import tempfile
 import time
 
-ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-sys.path.append(os.path.join(ROOT_DIR))
 
-from python.perfetto.prebuilts.perfetto_prebuilts import *
+# ----- Amalgamator: begin of python/perfetto/prebuilts/perfetto_prebuilts.py
+# Copyright (C) 2021 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.
+"""
+Functions to fetch pre-pinned Perfetto prebuilts.
+
+This function is used in different places:
+- Into the //tools/{trace_processor, traceconv} scripts, which are just plain
+  wrappers around executables.
+- Into the //tools/{heap_profiler, record_android_trace} scripts, which contain
+  some other hand-written python code.
+
+The manifest argument looks as follows:
+TRACECONV_MANIFEST = [
+  {
+    'arch': 'mac-amd64',
+    'file_name': 'traceconv',
+    'file_size': 7087080,
+    'url': https://commondatastorage.googleapis.com/.../trace_to_text',
+    'sha256': 7d957c005b0dc130f5bd855d6cec27e060d38841b320d04840afc569f9087490',
+    'platform': 'darwin',
+    'machine': 'x86_64'
+  },
+  ...
+]
+
+The intended usage is:
+
+  from perfetto.prebuilts.manifests.traceconv import TRACECONV_MANIFEST
+  bin_path = get_perfetto_prebuilt(TRACECONV_MANIFEST)
+  subprocess.call(bin_path, ...)
+"""
+
+import hashlib
+import os
+import platform
+import subprocess
+import sys
+import threading
+
+DOWNLOAD_LOCK = threading.Lock()
+
+
+def download_or_get_cached(file_name, url, sha256):
+  """ Downloads a prebuilt or returns a cached version
+
+  The first time this is invoked, it downloads the |url| and caches it into
+  ~/.local/share/perfetto/prebuilts/$tool_name. On subsequent invocations it
+  just runs the cached version.
+  """
+  dir = os.path.join(
+      os.path.expanduser('~'), '.local', 'share', 'perfetto', 'prebuilts')
+  os.makedirs(dir, exist_ok=True)
+  bin_path = os.path.join(dir, file_name)
+  sha256_path = os.path.join(dir, file_name + '.sha256')
+  needs_download = True
+
+  try:
+    # In BatchTraceProcessor, many threads can be trying to execute the below
+    # code in parallel. For this reason, protect the whole operation with a
+    # lock.
+    DOWNLOAD_LOCK.acquire()
+
+    # Avoid recomputing the SHA-256 on each invocation. The SHA-256 of the last
+    # download is cached into file_name.sha256, just check if that matches.
+    if os.path.exists(bin_path) and os.path.exists(sha256_path):
+      with open(sha256_path, 'rb') as f:
+        digest = f.read().decode()
+        if digest == sha256:
+          needs_download = False
+
+    if needs_download:
+      # Either the filed doesn't exist or the SHA256 doesn't match.
+      tmp_path = bin_path + '.tmp'
+      print('Downloading ' + url)
+      subprocess.check_call(['curl', '-f', '-L', '-#', '-o', tmp_path, url])
+      with open(tmp_path, 'rb') as fd:
+        actual_sha256 = hashlib.sha256(fd.read()).hexdigest()
+      if actual_sha256 != sha256:
+        raise Exception('Checksum mismatch for %s (actual: %s, expected: %s)' %
+                        (url, actual_sha256, sha256))
+      os.chmod(tmp_path, 0o755)
+      os.replace(tmp_path, bin_path)
+      with open(sha256_path, 'w') as f:
+        f.write(sha256)
+  finally:
+    DOWNLOAD_LOCK.release()
+  return bin_path
+
+
+def get_perfetto_prebuilt(manifest, soft_fail=False, arch=None):
+  """ Downloads the prebuilt, if necessary, and returns its path on disk. """
+  plat = sys.platform.lower()
+  machine = platform.machine().lower()
+  manifest_entry = None
+  for entry in manifest:
+    # If the caller overrides the arch, just match that (for Android prebuilts).
+    if arch:
+      if entry.get('arch') == arch:
+        manifest_entry = entry
+        break
+      continue
+    # Otherwise guess the local machine arch.
+    if entry.get('platform') == plat and machine in entry.get('machine', []):
+      manifest_entry = entry
+      break
+  if manifest_entry is None:
+    if soft_fail:
+      return None
+    raise Exception(
+        ('No prebuilts available for %s-%s\n' % (plat, machine)) +
+        'See https://perfetto.dev/docs/contributing/build-instructions')
+
+  return download_or_get_cached(
+      file_name=manifest_entry['file_name'],
+      url=manifest_entry['url'],
+      sha256=manifest_entry['sha256'])
+
+
+def run_perfetto_prebuilt(manifest):
+  bin_path = get_perfetto_prebuilt(manifest)
+  if sys.platform.lower() == 'win32':
+    sys.exit(subprocess.check_call([bin_path, *sys.argv[1:]]))
+  os.execv(bin_path, [bin_path] + sys.argv[1:])
+
+# ----- Amalgamator: end of python/perfetto/prebuilts/perfetto_prebuilts.py
 
 PERMSISION_REGEX = re.compile(r'''uses-permission: name='(.*)'.*''')
 NAME_REGEX = re.compile(r'''package: name='(.*?)' .*''')
diff --git a/ui/build.js b/ui/build.js
index cc6cfb9..698842c 100644
--- a/ui/build.js
+++ b/ui/build.js
@@ -243,7 +243,7 @@
     scanDir('ui/src/test/diff_viewer');
     scanDir('buildtools/typefaces');
     scanDir('buildtools/catapult_trace_viewer');
-    generateImports('ui/src/tracks', 'all_tracks.ts');
+    generateImports('ui/src/core_plugins', 'all_core_plugins.ts');
     generateImports('ui/src/plugins', 'all_plugins.ts');
     compileProtos();
     genVersion();
diff --git a/ui/release/channels.json b/ui/release/channels.json
index c87709e..6e49a92 100644
--- a/ui/release/channels.json
+++ b/ui/release/channels.json
@@ -2,7 +2,7 @@
   "channels": [
     {
       "name": "stable",
-      "rev": "b88536ad5a5569af9559fe0a3f295f12cff37751"
+      "rev": "257a029903d42e76d14a1039edc7de6cf6a10e25"
     },
     {
       "name": "canary",
diff --git a/ui/src/common/actions_unittest.ts b/ui/src/common/actions_unittest.ts
index b31d2ae..de497d7 100644
--- a/ui/src/common/actions_unittest.ts
+++ b/ui/src/common/actions_unittest.ts
@@ -17,10 +17,9 @@
 import {assertExists} from '../base/logging';
 import {Time} from '../base/time';
 import {PrimaryTrackSortKey} from '../public';
-import {SLICE_TRACK_KIND} from '../tracks/chrome_slices';
-import {HEAP_PROFILE_TRACK_KIND} from '../tracks/heap_profile';
-import {PROCESS_SCHEDULING_TRACK_KIND} from '../tracks/process_summary/process_scheduling_track';
-import {THREAD_STATE_TRACK_KIND} from '../tracks/thread_state';
+import {HEAP_PROFILE_TRACK_KIND} from '../core_plugins/heap_profile';
+import {PROCESS_SCHEDULING_TRACK_KIND} from '../core_plugins/process_summary/process_scheduling_track';
+import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
 
 import {StateActions} from './actions';
 import {createEmptyState} from './empty_state';
@@ -32,6 +31,7 @@
   TraceUrlSource,
   TrackSortKey,
 } from './state';
+import {SLICE_TRACK_KIND} from '../core_plugins/chrome_slices/chrome_slice_track';
 
 function fakeTrack(
   state: State,
diff --git a/ui/src/controller/aggregation/counter_aggregation_controller.ts b/ui/src/controller/aggregation/counter_aggregation_controller.ts
index 20afd9e..bc8b731 100644
--- a/ui/src/controller/aggregation/counter_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/counter_aggregation_controller.ts
@@ -17,7 +17,7 @@
 import {Area, Sorting} from '../../common/state';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {COUNTER_TRACK_KIND} from '../../tracks/counter';
+import {COUNTER_TRACK_KIND} from '../../core_plugins/counter';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/aggregation/cpu_aggregation_controller.ts b/ui/src/controller/aggregation/cpu_aggregation_controller.ts
index 01a8964..6b9083e 100644
--- a/ui/src/controller/aggregation/cpu_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/cpu_aggregation_controller.ts
@@ -17,7 +17,7 @@
 import {Area, Sorting} from '../../common/state';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {CPU_SLICE_TRACK_KIND} from '../../tracks/cpu_slices';
+import {CPU_SLICE_TRACK_KIND} from '../../core_plugins/cpu_slices';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts b/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts
index df18d98..03f29f2 100644
--- a/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/cpu_by_process_aggregation_controller.ts
@@ -17,7 +17,7 @@
 import {Area, Sorting} from '../../common/state';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {CPU_SLICE_TRACK_KIND} from '../../tracks/cpu_slices';
+import {CPU_SLICE_TRACK_KIND} from '../../core_plugins/cpu_slices';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/aggregation/frame_aggregation_controller.ts b/ui/src/controller/aggregation/frame_aggregation_controller.ts
index fc3c2ca..100e47b 100644
--- a/ui/src/controller/aggregation/frame_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/frame_aggregation_controller.ts
@@ -16,7 +16,7 @@
 import {Area, Sorting} from '../../common/state';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../../tracks/frames';
+import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../../core_plugins/frames';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/aggregation/slice_aggregation_controller.ts b/ui/src/controller/aggregation/slice_aggregation_controller.ts
index 6208594..89ba573 100644
--- a/ui/src/controller/aggregation/slice_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/slice_aggregation_controller.ts
@@ -16,8 +16,8 @@
 import {Area, Sorting} from '../../common/state';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {ASYNC_SLICE_TRACK_KIND} from '../../tracks/async_slices';
-import {SLICE_TRACK_KIND} from '../../tracks/chrome_slices';
+import {ASYNC_SLICE_TRACK_KIND} from '../../core_plugins/async_slices';
+import {SLICE_TRACK_KIND} from '../../core_plugins/chrome_slices/chrome_slice_track';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/aggregation/thread_aggregation_controller.ts b/ui/src/controller/aggregation/thread_aggregation_controller.ts
index c2f38e1..98e5c77 100644
--- a/ui/src/controller/aggregation/thread_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/thread_aggregation_controller.ts
@@ -19,7 +19,7 @@
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
 import {NUM, NUM_NULL, STR_NULL} from '../../trace_processor/query_result';
-import {THREAD_STATE_TRACK_KIND} from '../../tracks/thread_state';
+import {THREAD_STATE_TRACK_KIND} from '../../core_plugins/thread_state';
 
 import {AggregationController} from './aggregation_controller';
 
diff --git a/ui/src/controller/flamegraph_controller.ts b/ui/src/controller/flamegraph_controller.ts
index 22160d6..14436f7 100644
--- a/ui/src/controller/flamegraph_controller.ts
+++ b/ui/src/controller/flamegraph_controller.ts
@@ -32,7 +32,7 @@
 import {publishFlamegraphDetails} from '../frontend/publish';
 import {Engine} from '../trace_processor/engine';
 import {NUM, STR} from '../trace_processor/query_result';
-import {PERF_SAMPLES_PROFILE_TRACK_KIND} from '../tracks/perf_samples_profile';
+import {PERF_SAMPLES_PROFILE_TRACK_KIND} from '../core_plugins/perf_samples_profile';
 
 import {AreaSelectionHandler} from './area_selection_handler';
 import {Controller} from './controller';
diff --git a/ui/src/controller/flow_events_controller.ts b/ui/src/controller/flow_events_controller.ts
index d53142c..aa40c6e 100644
--- a/ui/src/controller/flow_events_controller.ts
+++ b/ui/src/controller/flow_events_controller.ts
@@ -20,8 +20,8 @@
 import {asSliceSqlId} from '../frontend/sql_types';
 import {Engine} from '../trace_processor/engine';
 import {LONG, NUM, STR_NULL} from '../trace_processor/query_result';
-import {SLICE_TRACK_KIND} from '../tracks/chrome_slices';
-import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../tracks/frames';
+import {SLICE_TRACK_KIND} from '../core_plugins/chrome_slices/chrome_slice_track';
+import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../core_plugins/frames';
 
 import {Controller} from './controller';
 
diff --git a/ui/src/controller/index.ts b/ui/src/controller/index.ts
index ffb481b..8154c18 100644
--- a/ui/src/controller/index.ts
+++ b/ui/src/controller/index.ts
@@ -12,7 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import '../gen/all_tracks';
 import '../common/recordingV2/target_factories';
 
 import {assertExists, assertTrue} from '../base/logging';
diff --git a/ui/src/controller/search_controller.ts b/ui/src/controller/search_controller.ts
index 61ada93..ba2d354 100644
--- a/ui/src/controller/search_controller.ts
+++ b/ui/src/controller/search_controller.ts
@@ -26,7 +26,7 @@
 import {Engine} from '../trace_processor/engine';
 import {LONG, NUM, STR} from '../trace_processor/query_result';
 import {escapeSearchQuery} from '../trace_processor/query_utils';
-import {CPU_SLICE_TRACK_KIND} from '../tracks/cpu_slices';
+import {CPU_SLICE_TRACK_KIND} from '../core_plugins/cpu_slices';
 
 import {Controller} from './controller';
 
diff --git a/ui/src/controller/selection_controller.ts b/ui/src/controller/selection_controller.ts
index d30b458..dc905ea 100644
--- a/ui/src/controller/selection_controller.ts
+++ b/ui/src/controller/selection_controller.ts
@@ -37,7 +37,7 @@
   STR_NULL,
   timeFromSql,
 } from '../trace_processor/query_result';
-import {SLICE_TRACK_KIND} from '../tracks/chrome_slices';
+import {SLICE_TRACK_KIND} from '../core_plugins/chrome_slices/chrome_slice_track';
 
 import {Controller} from './controller';
 
diff --git a/ui/src/controller/track_decider.ts b/ui/src/controller/track_decider.ts
index e7e66f9..f0c7ef4 100644
--- a/ui/src/controller/track_decider.ts
+++ b/ui/src/controller/track_decider.ts
@@ -29,20 +29,20 @@
 import {getTrackName} from '../public/utils';
 import {Engine, EngineProxy} from '../trace_processor/engine';
 import {NUM, NUM_NULL, STR, STR_NULL} from '../trace_processor/query_result';
-import {ASYNC_SLICE_TRACK_KIND} from '../tracks/async_slices';
+import {ASYNC_SLICE_TRACK_KIND} from '../core_plugins/async_slices';
 import {
   ENABLE_SCROLL_JANK_PLUGIN_V2,
   getScrollJankTracks,
-} from '../tracks/chrome_scroll_jank';
-import {decideTracks as scrollJankDecideTracks} from '../tracks/chrome_scroll_jank/chrome_tasks_scroll_jank_track';
-import {SLICE_TRACK_KIND} from '../tracks/chrome_slices';
-import {COUNTER_TRACK_KIND} from '../tracks/counter';
+} from '../core_plugins/chrome_scroll_jank';
+import {decideTracks as scrollJankDecideTracks} from '../core_plugins/chrome_scroll_jank/chrome_tasks_scroll_jank_track';
+import {COUNTER_TRACK_KIND} from '../core_plugins/counter';
 import {
   ACTUAL_FRAMES_SLICE_TRACK_KIND,
   EXPECTED_FRAMES_SLICE_TRACK_KIND,
-} from '../tracks/frames';
-import {decideTracks as screenshotDecideTracks} from '../tracks/screenshots';
-import {THREAD_STATE_TRACK_KIND} from '../tracks/thread_state';
+} from '../core_plugins/frames';
+import {decideTracks as screenshotDecideTracks} from '../core_plugins/screenshots';
+import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
+import {SLICE_TRACK_KIND} from '../core_plugins/chrome_slices/chrome_slice_track';
 
 const MEM_DMA_COUNTER_NAME = 'mem.dma_heap';
 const MEM_DMA = 'mem.dma_buffer';
@@ -805,7 +805,7 @@
         t.uid as uid,
         iif(g.cnt = 1, g.package_name, 'UID ' || g.uid) as packageName
       from _uid_track_track_summary_by_uid_and_name t
-      join grouped_packages g using (uid)
+      left join grouped_packages g using (uid)
     `);
 
     const it = result.iter({
@@ -823,7 +823,7 @@
       }
       const rawName = it.name;
       const uid = it.uid === null ? undefined : it.uid;
-      const userName = it.packageName === null ? `UID: ${uid}` : it.packageName;
+      const userName = it.packageName === null ? `UID ${uid}` : it.packageName;
 
       const groupUuid = `uid-track-group${rawName}`;
       if (groupMap.get(rawName) === undefined) {
diff --git a/ui/src/tracks/android_log/index.ts b/ui/src/core_plugins/android_log/index.ts
similarity index 100%
rename from ui/src/tracks/android_log/index.ts
rename to ui/src/core_plugins/android_log/index.ts
diff --git a/ui/src/tracks/android_log/logs_panel.ts b/ui/src/core_plugins/android_log/logs_panel.ts
similarity index 100%
rename from ui/src/tracks/android_log/logs_panel.ts
rename to ui/src/core_plugins/android_log/logs_panel.ts
diff --git a/ui/src/tracks/android_log/logs_track.ts b/ui/src/core_plugins/android_log/logs_track.ts
similarity index 100%
rename from ui/src/tracks/android_log/logs_track.ts
rename to ui/src/core_plugins/android_log/logs_track.ts
diff --git a/ui/src/tracks/annotation/index.ts b/ui/src/core_plugins/annotation/index.ts
similarity index 90%
rename from ui/src/tracks/annotation/index.ts
rename to ui/src/core_plugins/annotation/index.ts
index d36e9a2..bda66f3 100644
--- a/ui/src/tracks/annotation/index.ts
+++ b/ui/src/core_plugins/annotation/index.ts
@@ -13,8 +13,11 @@
 // limitations under the License.
 
 import {Plugin, PluginContextTrace, PluginDescriptor} from '../../public';
+import {
+  ChromeSliceTrack,
+  SLICE_TRACK_KIND,
+} from '../chrome_slices/chrome_slice_track';
 import {NUM, NUM_NULL, STR} from '../../trace_processor/query_result';
-import {ChromeSliceTrack, SLICE_TRACK_KIND} from '../chrome_slices/';
 import {COUNTER_TRACK_KIND, TraceProcessorCounterTrack} from '../counter';
 
 class AnnotationPlugin implements Plugin {
@@ -49,7 +52,15 @@
           metric: true,
         },
         trackFactory: ({trackKey}) => {
-          return new ChromeSliceTrack(engine, 0, trackKey, id, 'annotation');
+          return new ChromeSliceTrack(
+            {
+              engine: ctx.engine,
+              trackKey,
+            },
+            id,
+            0,
+            'annotation_slice',
+          );
         },
       });
     }
diff --git a/ui/src/tracks/async_slices/async_slice_track_v2.ts b/ui/src/core_plugins/async_slices/async_slice_track_v2.ts
similarity index 100%
rename from ui/src/tracks/async_slices/async_slice_track_v2.ts
rename to ui/src/core_plugins/async_slices/async_slice_track_v2.ts
diff --git a/ui/src/tracks/async_slices/index.ts b/ui/src/core_plugins/async_slices/index.ts
similarity index 97%
rename from ui/src/tracks/async_slices/index.ts
rename to ui/src/core_plugins/async_slices/index.ts
index 49520a0..644d22f 100644
--- a/ui/src/tracks/async_slices/index.ts
+++ b/ui/src/core_plugins/async_slices/index.ts
@@ -151,7 +151,7 @@
         __max_layout_depth(t.track_count, t.track_ids) as maxDepth,
         iif(g.cnt = 1, g.package_name, 'UID ' || g.uid) as packageName
       from _uid_track_track_summary_by_uid_and_name t
-      join grouped_packages g using (uid)
+      left join grouped_packages g using (uid)
     `);
 
     const it = result.iter({
@@ -165,8 +165,8 @@
     for (; it.valid(); it.next()) {
       const kind = ASYNC_SLICE_TRACK_KIND;
       const rawName = it.name === null ? undefined : it.name;
-      const userName = it.packageName === null ? undefined : it.packageName;
       const uid = it.uid === null ? undefined : it.uid;
+      const userName = it.packageName === null ? `UID ${uid}` : it.packageName;
       const rawTrackIds = it.trackIds;
       const trackIds = rawTrackIds.split(',').map((v) => Number(v));
       const maxDepth = it.maxDepth;
diff --git a/ui/src/tracks/chrome_critical_user_interactions/index.ts b/ui/src/core_plugins/chrome_critical_user_interactions/index.ts
similarity index 100%
rename from ui/src/tracks/chrome_critical_user_interactions/index.ts
rename to ui/src/core_plugins/chrome_critical_user_interactions/index.ts
diff --git a/ui/src/tracks/chrome_critical_user_interactions/page_load_details_panel.ts b/ui/src/core_plugins/chrome_critical_user_interactions/page_load_details_panel.ts
similarity index 100%
rename from ui/src/tracks/chrome_critical_user_interactions/page_load_details_panel.ts
rename to ui/src/core_plugins/chrome_critical_user_interactions/page_load_details_panel.ts
diff --git a/ui/src/tracks/chrome_critical_user_interactions/startup_details_panel.ts b/ui/src/core_plugins/chrome_critical_user_interactions/startup_details_panel.ts
similarity index 100%
rename from ui/src/tracks/chrome_critical_user_interactions/startup_details_panel.ts
rename to ui/src/core_plugins/chrome_critical_user_interactions/startup_details_panel.ts
diff --git a/ui/src/tracks/chrome_critical_user_interactions/web_content_interaction_details_panel.ts b/ui/src/core_plugins/chrome_critical_user_interactions/web_content_interaction_details_panel.ts
similarity index 100%
rename from ui/src/tracks/chrome_critical_user_interactions/web_content_interaction_details_panel.ts
rename to ui/src/core_plugins/chrome_critical_user_interactions/web_content_interaction_details_panel.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/chrome_tasks_scroll_jank_track.ts b/ui/src/core_plugins/chrome_scroll_jank/chrome_tasks_scroll_jank_track.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/chrome_tasks_scroll_jank_track.ts
rename to ui/src/core_plugins/chrome_scroll_jank/chrome_tasks_scroll_jank_track.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts b/ui/src/core_plugins/chrome_scroll_jank/event_latency_details_panel.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/event_latency_details_panel.ts
rename to ui/src/core_plugins/chrome_scroll_jank/event_latency_details_panel.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/event_latency_track.ts b/ui/src/core_plugins/chrome_scroll_jank/event_latency_track.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/event_latency_track.ts
rename to ui/src/core_plugins/chrome_scroll_jank/event_latency_track.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/index.ts b/ui/src/core_plugins/chrome_scroll_jank/index.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/index.ts
rename to ui/src/core_plugins/chrome_scroll_jank/index.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/jank_colors.ts b/ui/src/core_plugins/chrome_scroll_jank/jank_colors.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/jank_colors.ts
rename to ui/src/core_plugins/chrome_scroll_jank/jank_colors.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_delta_graph.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_delta_graph.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/scroll_delta_graph.ts
rename to ui/src/core_plugins/chrome_scroll_jank/scroll_delta_graph.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_details_panel.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/scroll_details_panel.ts
rename to ui/src/core_plugins/chrome_scroll_jank/scroll_details_panel.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_link_utils.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_cause_link_utils.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_link_utils.ts
rename to ui/src/core_plugins/chrome_scroll_jank/scroll_jank_cause_link_utils.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_map.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_cause_map.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/scroll_jank_cause_map.ts
rename to ui/src/core_plugins/chrome_scroll_jank/scroll_jank_cause_map.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_jank_slice.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_slice.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/scroll_jank_slice.ts
rename to ui/src/core_plugins/chrome_scroll_jank/scroll_jank_slice.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_details_panel.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_v3_details_panel.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_details_panel.ts
rename to ui/src/core_plugins/chrome_scroll_jank/scroll_jank_v3_details_panel.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_track.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_v3_track.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_track.ts
rename to ui/src/core_plugins/chrome_scroll_jank/scroll_jank_v3_track.ts
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_track.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_track.ts
similarity index 100%
rename from ui/src/tracks/chrome_scroll_jank/scroll_track.ts
rename to ui/src/core_plugins/chrome_scroll_jank/scroll_track.ts
diff --git a/ui/src/core_plugins/chrome_slices/chrome_slice_track.ts b/ui/src/core_plugins/chrome_slices/chrome_slice_track.ts
new file mode 100644
index 0000000..c9fda8b
--- /dev/null
+++ b/ui/src/core_plugins/chrome_slices/chrome_slice_track.ts
@@ -0,0 +1,109 @@
+// Copyright (C) 2024 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.
+
+import {BigintMath as BIMath} from '../../base/bigint_math';
+import {clamp} from '../../base/math_utils';
+import {OnSliceClickArgs} from '../../frontend/base_slice_track';
+import {globals} from '../../frontend/globals';
+import {
+  NAMED_ROW,
+  NamedSliceTrack,
+  NamedSliceTrackTypes,
+} from '../../frontend/named_slice_track';
+import {SLICE_LAYOUT_FIT_CONTENT_DEFAULTS} from '../../frontend/slice_layout';
+import {NewTrackArgs} from '../../frontend/track';
+import {LONG_NULL} from '../../trace_processor/query_result';
+
+export const SLICE_TRACK_KIND = 'ChromeSliceTrack';
+
+export const CHROME_SLICE_ROW = {
+  // Base columns (tsq, ts, dur, id, depth).
+  ...NAMED_ROW,
+
+  // Chrome-specific columns.
+  threadDur: LONG_NULL,
+};
+export type ChromeSliceRow = typeof CHROME_SLICE_ROW;
+
+export interface ChromeSliceTrackTypes extends NamedSliceTrackTypes {
+  row: ChromeSliceRow;
+}
+
+export class ChromeSliceTrack extends NamedSliceTrack<ChromeSliceTrackTypes> {
+  constructor(
+    args: NewTrackArgs,
+    private trackId: number,
+    maxDepth: number,
+    private tableName: string = 'slice',
+  ) {
+    super(args);
+    this.sliceLayout = {
+      ...SLICE_LAYOUT_FIT_CONTENT_DEFAULTS,
+      depthGuess: maxDepth,
+    };
+  }
+
+  // This is used by the base class to call iter().
+  getRowSpec() {
+    return CHROME_SLICE_ROW;
+  }
+
+  getSqlSource(): string {
+    return `select
+      ts,
+      dur,
+      id,
+      depth,
+      ifnull(name, '') as name,
+      thread_dur as threadDur
+    from ${this.tableName}
+    where track_id = ${this.trackId}`;
+  }
+
+  // Converts a SQL result row to an "Impl" Slice.
+  rowToSlice(
+    row: ChromeSliceTrackTypes['row'],
+  ): ChromeSliceTrackTypes['slice'] {
+    const namedSlice = super.rowToSlice(row);
+
+    if (row.dur > 0n && row.threadDur !== null) {
+      const fillRatio = clamp(BIMath.ratio(row.threadDur, row.dur), 0, 1);
+      return {...namedSlice, fillRatio};
+    } else {
+      return namedSlice;
+    }
+  }
+
+  onUpdatedSlices(slices: ChromeSliceTrackTypes['slice'][]) {
+    for (const slice of slices) {
+      slice.isHighlighted = slice === this.hoveredSlice;
+    }
+  }
+
+  onSliceClick(args: OnSliceClickArgs<ChromeSliceTrackTypes['slice']>) {
+    globals.setLegacySelection(
+      {
+        kind: 'CHROME_SLICE',
+        id: args.slice.id,
+        trackKey: this.trackKey,
+        table: this.tableName,
+      },
+      {
+        clearSearch: true,
+        pendingScrollId: undefined,
+        switchToCurrentSelectionTab: true,
+      },
+    );
+  }
+}
diff --git a/ui/src/core_plugins/chrome_slices/index.ts b/ui/src/core_plugins/chrome_slices/index.ts
new file mode 100644
index 0000000..830b7e4
--- /dev/null
+++ b/ui/src/core_plugins/chrome_slices/index.ts
@@ -0,0 +1,116 @@
+// Copyright (C) 2021 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.
+
+import {uuidv4} from '../../base/uuid';
+import {ChromeSliceDetailsTab} from '../../frontend/chrome_slice_details_tab';
+import {
+  BottomTabToSCSAdapter,
+  Plugin,
+  PluginContextTrace,
+  PluginDescriptor,
+} from '../../public';
+import {getTrackName} from '../../public/utils';
+import {NUM, NUM_NULL, STR_NULL} from '../../trace_processor/query_result';
+import {ChromeSliceTrack, SLICE_TRACK_KIND} from './chrome_slice_track';
+
+class ChromeSlicesPlugin implements Plugin {
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    const {engine} = ctx;
+    const result = await engine.query(`
+        with max_depth_materialized as (
+          select track_id, max(depth) as maxDepth
+          from slice
+          group by track_id
+        )
+        select
+          thread_track.utid as utid,
+          thread_track.id as trackId,
+          thread_track.name as trackName,
+          EXTRACT_ARG(thread_track.source_arg_set_id,
+                      'is_root_in_scope') as isDefaultTrackForScope,
+          tid,
+          thread.name as threadName,
+          maxDepth,
+          thread.upid as upid
+        from thread_track
+        join thread using(utid)
+        join max_depth_materialized mdd on mdd.track_id = thread_track.id
+  `);
+
+    const it = result.iter({
+      utid: NUM,
+      trackId: NUM,
+      trackName: STR_NULL,
+      isDefaultTrackForScope: NUM_NULL,
+      tid: NUM_NULL,
+      threadName: STR_NULL,
+      maxDepth: NUM,
+      upid: NUM_NULL,
+    });
+
+    for (; it.valid(); it.next()) {
+      const utid = it.utid;
+      const trackId = it.trackId;
+      const trackName = it.trackName;
+      const tid = it.tid;
+      const threadName = it.threadName;
+      const maxDepth = it.maxDepth;
+
+      const displayName = getTrackName({
+        name: trackName,
+        utid,
+        tid,
+        threadName,
+        kind: 'Slices',
+      });
+
+      ctx.registerTrack({
+        uri: `perfetto.ChromeSlices#${trackId}`,
+        displayName,
+        trackIds: [trackId],
+        kind: SLICE_TRACK_KIND,
+        trackFactory: ({trackKey}) => {
+          const newTrackArgs = {
+            engine: ctx.engine,
+            trackKey,
+          };
+          return new ChromeSliceTrack(newTrackArgs, trackId, maxDepth);
+        },
+      });
+    }
+
+    ctx.registerDetailsPanel(
+      new BottomTabToSCSAdapter({
+        tabFactory: (sel) => {
+          if (sel.kind !== 'CHROME_SLICE') {
+            return undefined;
+          }
+          return new ChromeSliceDetailsTab({
+            config: {
+              table: sel.table ?? 'slice',
+              id: sel.id,
+            },
+            engine: ctx.engine,
+            uuid: uuidv4(),
+          });
+        },
+      }),
+    );
+  }
+}
+
+export const plugin: PluginDescriptor = {
+  pluginId: 'perfetto.ChromeSlices',
+  plugin: ChromeSlicesPlugin,
+};
diff --git a/ui/src/tracks/counter/index.ts b/ui/src/core_plugins/counter/index.ts
similarity index 100%
rename from ui/src/tracks/counter/index.ts
rename to ui/src/core_plugins/counter/index.ts
diff --git a/ui/src/tracks/cpu_freq/index.ts b/ui/src/core_plugins/cpu_freq/index.ts
similarity index 100%
rename from ui/src/tracks/cpu_freq/index.ts
rename to ui/src/core_plugins/cpu_freq/index.ts
diff --git a/ui/src/tracks/cpu_profile/index.ts b/ui/src/core_plugins/cpu_profile/index.ts
similarity index 100%
rename from ui/src/tracks/cpu_profile/index.ts
rename to ui/src/core_plugins/cpu_profile/index.ts
diff --git a/ui/src/tracks/cpu_slices/index.ts b/ui/src/core_plugins/cpu_slices/index.ts
similarity index 100%
rename from ui/src/tracks/cpu_slices/index.ts
rename to ui/src/core_plugins/cpu_slices/index.ts
diff --git a/ui/src/tracks/custom_sql_table_slices/index.ts b/ui/src/core_plugins/custom_sql_table_slices/index.ts
similarity index 100%
rename from ui/src/tracks/custom_sql_table_slices/index.ts
rename to ui/src/core_plugins/custom_sql_table_slices/index.ts
diff --git a/ui/src/tracks/debug/add_debug_track_menu.ts b/ui/src/core_plugins/debug/add_debug_track_menu.ts
similarity index 100%
rename from ui/src/tracks/debug/add_debug_track_menu.ts
rename to ui/src/core_plugins/debug/add_debug_track_menu.ts
diff --git a/ui/src/tracks/debug/counter_track.ts b/ui/src/core_plugins/debug/counter_track.ts
similarity index 100%
rename from ui/src/tracks/debug/counter_track.ts
rename to ui/src/core_plugins/debug/counter_track.ts
diff --git a/ui/src/tracks/debug/details_tab.ts b/ui/src/core_plugins/debug/details_tab.ts
similarity index 100%
rename from ui/src/tracks/debug/details_tab.ts
rename to ui/src/core_plugins/debug/details_tab.ts
diff --git a/ui/src/tracks/debug/index.ts b/ui/src/core_plugins/debug/index.ts
similarity index 100%
rename from ui/src/tracks/debug/index.ts
rename to ui/src/core_plugins/debug/index.ts
diff --git a/ui/src/tracks/debug/slice_track.ts b/ui/src/core_plugins/debug/slice_track.ts
similarity index 100%
rename from ui/src/tracks/debug/slice_track.ts
rename to ui/src/core_plugins/debug/slice_track.ts
diff --git a/ui/src/tracks/frames/actual_frames_track_v2.ts b/ui/src/core_plugins/frames/actual_frames_track_v2.ts
similarity index 100%
rename from ui/src/tracks/frames/actual_frames_track_v2.ts
rename to ui/src/core_plugins/frames/actual_frames_track_v2.ts
diff --git a/ui/src/tracks/frames/expected_frames_track_v2.ts b/ui/src/core_plugins/frames/expected_frames_track_v2.ts
similarity index 100%
rename from ui/src/tracks/frames/expected_frames_track_v2.ts
rename to ui/src/core_plugins/frames/expected_frames_track_v2.ts
diff --git a/ui/src/tracks/frames/index.ts b/ui/src/core_plugins/frames/index.ts
similarity index 100%
rename from ui/src/tracks/frames/index.ts
rename to ui/src/core_plugins/frames/index.ts
diff --git a/ui/src/tracks/ftrace/common.ts b/ui/src/core_plugins/ftrace/common.ts
similarity index 100%
rename from ui/src/tracks/ftrace/common.ts
rename to ui/src/core_plugins/ftrace/common.ts
diff --git a/ui/src/tracks/ftrace/ftrace_explorer.ts b/ui/src/core_plugins/ftrace/ftrace_explorer.ts
similarity index 100%
rename from ui/src/tracks/ftrace/ftrace_explorer.ts
rename to ui/src/core_plugins/ftrace/ftrace_explorer.ts
diff --git a/ui/src/tracks/ftrace/ftrace_track.ts b/ui/src/core_plugins/ftrace/ftrace_track.ts
similarity index 100%
rename from ui/src/tracks/ftrace/ftrace_track.ts
rename to ui/src/core_plugins/ftrace/ftrace_track.ts
diff --git a/ui/src/tracks/ftrace/index.ts b/ui/src/core_plugins/ftrace/index.ts
similarity index 100%
rename from ui/src/tracks/ftrace/index.ts
rename to ui/src/core_plugins/ftrace/index.ts
diff --git a/ui/src/tracks/heap_profile/index.ts b/ui/src/core_plugins/heap_profile/index.ts
similarity index 100%
rename from ui/src/tracks/heap_profile/index.ts
rename to ui/src/core_plugins/heap_profile/index.ts
diff --git a/ui/src/tracks/perf_samples_profile/index.ts b/ui/src/core_plugins/perf_samples_profile/index.ts
similarity index 100%
rename from ui/src/tracks/perf_samples_profile/index.ts
rename to ui/src/core_plugins/perf_samples_profile/index.ts
diff --git a/ui/src/tracks/process_summary/index.ts b/ui/src/core_plugins/process_summary/index.ts
similarity index 100%
rename from ui/src/tracks/process_summary/index.ts
rename to ui/src/core_plugins/process_summary/index.ts
diff --git a/ui/src/tracks/process_summary/process_scheduling_track.ts b/ui/src/core_plugins/process_summary/process_scheduling_track.ts
similarity index 100%
rename from ui/src/tracks/process_summary/process_scheduling_track.ts
rename to ui/src/core_plugins/process_summary/process_scheduling_track.ts
diff --git a/ui/src/tracks/process_summary/process_summary_track.ts b/ui/src/core_plugins/process_summary/process_summary_track.ts
similarity index 100%
rename from ui/src/tracks/process_summary/process_summary_track.ts
rename to ui/src/core_plugins/process_summary/process_summary_track.ts
diff --git a/ui/src/tracks/sched/active_cpu_count.ts b/ui/src/core_plugins/sched/active_cpu_count.ts
similarity index 100%
rename from ui/src/tracks/sched/active_cpu_count.ts
rename to ui/src/core_plugins/sched/active_cpu_count.ts
diff --git a/ui/src/tracks/sched/index.ts b/ui/src/core_plugins/sched/index.ts
similarity index 100%
rename from ui/src/tracks/sched/index.ts
rename to ui/src/core_plugins/sched/index.ts
diff --git a/ui/src/tracks/sched/runnable_thread_count.ts b/ui/src/core_plugins/sched/runnable_thread_count.ts
similarity index 100%
rename from ui/src/tracks/sched/runnable_thread_count.ts
rename to ui/src/core_plugins/sched/runnable_thread_count.ts
diff --git a/ui/src/tracks/screenshots/index.ts b/ui/src/core_plugins/screenshots/index.ts
similarity index 100%
rename from ui/src/tracks/screenshots/index.ts
rename to ui/src/core_plugins/screenshots/index.ts
diff --git a/ui/src/tracks/screenshots/screenshot_panel.ts b/ui/src/core_plugins/screenshots/screenshot_panel.ts
similarity index 100%
rename from ui/src/tracks/screenshots/screenshot_panel.ts
rename to ui/src/core_plugins/screenshots/screenshot_panel.ts
diff --git a/ui/src/tracks/thread_state/index.ts b/ui/src/core_plugins/thread_state/index.ts
similarity index 100%
rename from ui/src/tracks/thread_state/index.ts
rename to ui/src/core_plugins/thread_state/index.ts
diff --git a/ui/src/tracks/thread_state/thread_state_v2.ts b/ui/src/core_plugins/thread_state/thread_state_v2.ts
similarity index 100%
rename from ui/src/tracks/thread_state/thread_state_v2.ts
rename to ui/src/core_plugins/thread_state/thread_state_v2.ts
diff --git a/ui/src/core_plugins/visualised_args/index.ts b/ui/src/core_plugins/visualised_args/index.ts
new file mode 100644
index 0000000..459a7ad
--- /dev/null
+++ b/ui/src/core_plugins/visualised_args/index.ts
@@ -0,0 +1,50 @@
+// Copyright (C) 2022 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.
+
+import {Plugin, PluginContextTrace, PluginDescriptor} from '../../public';
+import {
+  VISUALISED_ARGS_SLICE_TRACK_URI,
+  VisualisedArgsState,
+} from '../../frontend/visualized_args_tracks';
+import {VisualisedArgsTrack} from './visualized_args_track';
+
+class VisualisedArgsPlugin implements Plugin {
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    ctx.registerTrack({
+      uri: VISUALISED_ARGS_SLICE_TRACK_URI,
+      tags: {
+        metric: true, // TODO(stevegolton): Is this track really a metric?
+      },
+      trackFactory: (trackCtx) => {
+        // TODO(stevegolton): Validate params properly. Note, this is no
+        // worse than the situation we had before with track config.
+        const params = trackCtx.params as VisualisedArgsState;
+        return new VisualisedArgsTrack(
+          {
+            engine: ctx.engine,
+            trackKey: trackCtx.trackKey,
+          },
+          params.trackId,
+          params.maxDepth,
+          params.argName,
+        );
+      },
+    });
+  }
+}
+
+export const plugin: PluginDescriptor = {
+  pluginId: 'perfetto.VisualisedArgs',
+  plugin: VisualisedArgsPlugin,
+};
diff --git a/ui/src/core_plugins/visualised_args/visualized_args_track.ts b/ui/src/core_plugins/visualised_args/visualized_args_track.ts
new file mode 100644
index 0000000..8de85d3
--- /dev/null
+++ b/ui/src/core_plugins/visualised_args/visualized_args_track.ts
@@ -0,0 +1,93 @@
+// Copyright (C) 2024 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.
+
+import m from 'mithril';
+
+import {Actions} from '../../common/actions';
+import {globals} from '../../frontend/globals';
+import {Button} from '../../widgets/button';
+import {Icons} from '../../base/semantic_icons';
+import {ChromeSliceTrack} from '../chrome_slices/chrome_slice_track';
+import {uuidv4Sql} from '../../base/uuid';
+import {NewTrackArgs} from '../../frontend/track';
+import {Disposable, DisposableCallback} from '../../base/disposable';
+
+// Similar to a SliceTrack, but creates a view
+export class VisualisedArgsTrack extends ChromeSliceTrack {
+  private viewName: string;
+
+  constructor(
+    args: NewTrackArgs,
+    trackId: number,
+    maxDepth: number,
+    private argName: string,
+  ) {
+    const uuid = uuidv4Sql();
+    const escapedArgName = argName.replace(/[^a-zA-Z]/g, '_');
+    const viewName = `__arg_visualisation_helper_${escapedArgName}_${uuid}_slice`;
+    super(args, trackId, maxDepth, viewName);
+    this.viewName = viewName;
+  }
+
+  async onInit(): Promise<Disposable> {
+    // Create the helper view - just one which is relevant to this slice
+    await this.engine.query(`
+        create view ${this.viewName} as
+        with slice_with_arg as (
+          select
+            slice.id,
+            slice.track_id,
+            slice.ts,
+            slice.dur,
+            slice.thread_dur,
+            NULL as cat,
+            args.display_value as name
+          from slice
+          join args using (arg_set_id)
+          where args.key='${this.argName}'
+        )
+        select
+          *,
+          (select count()
+          from ancestor_slice(s1.id) s2
+          join slice_with_arg s3 on s2.id=s3.id
+          ) as depth
+        from slice_with_arg s1
+        order by id;
+    `);
+
+    return new DisposableCallback(() => {
+      if (this.engine.isAlive) {
+        this.engine.query(`drop view ${this.viewName}`);
+      }
+    });
+  }
+
+  getTrackShellButtons(): m.Children {
+    return m(Button, {
+      onclick: () => {
+        // This behavior differs to the original behavior a little.
+        // Originally, hitting the close button on a single track removed ALL
+        // tracks with this argName, whereas this one only closes the single
+        // track.
+        // This will be easily fixable once we transition to using dynamic
+        // tracks instead of this "initial state" approach to add these tracks.
+        globals.dispatch(Actions.removeTracks({trackKeys: [this.trackKey]}));
+      },
+      icon: Icons.Close,
+      title: 'Close',
+      compact: true,
+    });
+  }
+}
diff --git a/ui/src/frontend/app.ts b/ui/src/frontend/app.ts
index 3dea6fd..92301b7 100644
--- a/ui/src/frontend/app.ts
+++ b/ui/src/frontend/app.ts
@@ -20,7 +20,7 @@
 import {FuzzyFinder} from '../base/fuzzy';
 import {assertExists} from '../base/logging';
 import {undoCommonChatAppReplacements} from '../base/string_utils';
-import {duration, Span, Time, time, TimeSpan} from '../base/time';
+import {duration, Span, time, TimeSpan} from '../base/time';
 import {Actions} from '../common/actions';
 import {getLegacySelection} from '../common/state';
 import {runQuery} from '../common/queries';
@@ -33,7 +33,7 @@
 import {raf} from '../core/raf_scheduler';
 import {Command} from '../public';
 import {EngineProxy} from '../trace_processor/engine';
-import {THREAD_STATE_TRACK_KIND} from '../tracks/thread_state';
+import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
 import {HotkeyConfig, HotkeyContext} from '../widgets/hotkey_context';
 import {HotkeyGlyphs} from '../widgets/hotkey_glyphs';
 import {maybeRenderFullscreenModalDialog} from '../widgets/modal';
@@ -62,6 +62,7 @@
   lockSliceSpan,
   moveByFocusedFlow,
 } from './keyboard_event_handler';
+import {exists} from '../base/utils';
 
 function renderPermalink(): m.Children {
   const permalink = globals.state.permalink;
@@ -1009,7 +1010,7 @@
 // there is no current selection.
 function getTimeSpanOfSelectionOrVisibleWindow(): Span<time, duration> {
   const range = globals.findTimeRangeOfSelection();
-  if (range.end !== Time.INVALID && range.start !== Time.INVALID) {
+  if (exists(range)) {
     return new TimeSpan(range.start, range.end);
   } else {
     return globals.stateVisibleTime();
diff --git a/ui/src/frontend/chrome_slice_details_tab.ts b/ui/src/frontend/chrome_slice_details_tab.ts
index f42d4b6..49196fa 100644
--- a/ui/src/frontend/chrome_slice_details_tab.ts
+++ b/ui/src/frontend/chrome_slice_details_tab.ts
@@ -222,7 +222,7 @@
   id: number,
   table: string,
 ): Promise<SliceDetails | undefined> {
-  if (table === 'annotation') {
+  if (table === 'annotation_slice') {
     return getAnnotationSlice(engine, id);
   } else {
     return getSlice(engine, asSliceSqlId(id));
diff --git a/ui/src/frontend/debug_tracks.ts b/ui/src/frontend/debug_tracks.ts
index 0a39612..ed77bf0 100644
--- a/ui/src/frontend/debug_tracks.ts
+++ b/ui/src/frontend/debug_tracks.ts
@@ -17,7 +17,7 @@
 import {SCROLLING_TRACK_GROUP} from '../common/state';
 import {globals} from './globals';
 import {EngineProxy, PrimaryTrackSortKey} from '../public';
-import {DebugTrackV2Config} from '../tracks/debug/slice_track';
+import {DebugTrackV2Config} from '../core_plugins/debug/slice_track';
 
 export const ARG_PREFIX = 'arg_';
 export const DEBUG_SLICE_TRACK_URI = 'perfetto.DebugSlices';
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 68e16a5..50b2286 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -55,6 +55,7 @@
 import {SliceSqlId} from './sql_types';
 import {PxSpan, TimeScale} from './time_scale';
 import {SelectionManager, LegacySelection} from '../core/selection_manager';
+import {exists} from '../base/utils';
 
 const INSTANT_FOCUS_DURATION = 1n;
 const INCOMPLETE_SLICE_DURATION = 30_000n;
@@ -786,46 +787,25 @@
     return Time.sub(ts, this.timestampOffset());
   }
 
-  findTimeRangeOfSelection(): {start: time; end: time} {
+  findTimeRangeOfSelection(): {start: time; end: time} | undefined {
     const selection = getLegacySelection(this.state);
-    let start = Time.INVALID;
-    let end = Time.INVALID;
     if (selection === null) {
-      return {start, end};
-    } else if (
-      selection.kind === 'SLICE' ||
-      selection.kind === 'CHROME_SLICE'
-    ) {
+      return undefined;
+    }
+
+    if (selection.kind === 'SLICE' || selection.kind === 'CHROME_SLICE') {
       const slice = this.sliceDetails;
-      if (slice.ts && slice.dur !== undefined && slice.dur > 0) {
-        start = slice.ts;
-        end = Time.add(start, slice.dur);
-      } else if (slice.ts) {
-        start = slice.ts;
-        // This will handle either:
-        // a)slice.dur === -1 -> unfinished slice
-        // b)slice.dur === 0  -> instant event
-        end =
-          slice.dur === -1n
-            ? Time.add(start, INCOMPLETE_SLICE_DURATION)
-            : Time.add(start, INSTANT_FOCUS_DURATION);
-      }
+      return findTimeRangeOfSlice(slice);
     } else if (selection.kind === 'THREAD_STATE') {
       const threadState = this.threadStateDetails;
-      // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
-      if (threadState.ts && threadState.dur) {
-        start = threadState.ts;
-        end = Time.add(start, threadState.dur);
-      }
+      return findTimeRangeOfSlice(threadState);
     } else if (selection.kind === 'COUNTER') {
-      start = selection.leftTs;
-      end = selection.rightTs;
+      return {start: selection.leftTs, end: selection.rightTs};
     } else if (selection.kind === 'AREA') {
       const selectedArea = this.state.areas[selection.areaId];
       // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
       if (selectedArea) {
-        start = selectedArea.start;
-        end = selectedArea.end;
+        return {start: selectedArea.start, end: selectedArea.end};
       }
     } else if (selection.kind === 'NOTE') {
       const selectedNote = this.state.notes[selection.id];
@@ -833,21 +813,21 @@
       // above in the AREA case.
       // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
       if (selectedNote && selectedNote.noteType === 'DEFAULT') {
-        start = selectedNote.timestamp;
-        end = Time.add(selectedNote.timestamp, INSTANT_FOCUS_DURATION);
+        return {
+          start: selectedNote.timestamp,
+          end: Time.add(selectedNote.timestamp, INSTANT_FOCUS_DURATION),
+        };
       }
     } else if (selection.kind === 'LOG') {
       // TODO(hjd): Make focus selection work for logs.
     } else if (selection.kind === 'GENERIC_SLICE') {
-      start = selection.start;
-      if (selection.duration > 0) {
-        end = Time.add(start, selection.duration);
-      } else {
-        end = Time.add(start, INSTANT_FOCUS_DURATION);
-      }
+      return findTimeRangeOfSlice({
+        ts: selection.start,
+        dur: selection.duration,
+      });
     }
 
-    return {start, end};
+    return undefined;
   }
 
   panToTimestamp(ts: time): void {
@@ -855,4 +835,34 @@
   }
 }
 
+interface SliceLike {
+  ts: time;
+  dur: duration;
+}
+
+// Returns the start and end points of a slice-like object If slice is instant
+// or incomplete, dummy time will be returned which instead.
+function findTimeRangeOfSlice(slice: Partial<SliceLike>): {
+  start: time;
+  end: time;
+} {
+  if (exists(slice.ts) && exists(slice.dur)) {
+    if (slice.dur === -1n) {
+      return {
+        start: slice.ts,
+        end: Time.add(slice.ts, INCOMPLETE_SLICE_DURATION),
+      };
+    } else if (slice.dur === 0n) {
+      return {
+        start: slice.ts,
+        end: Time.add(slice.ts, INSTANT_FOCUS_DURATION),
+      };
+    } else {
+      return {start: slice.ts, end: Time.add(slice.ts, slice.dur)};
+    }
+  } else {
+    return {start: Time.INVALID, end: Time.INVALID};
+  }
+}
+
 export const globals = new Globals();
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 468d9af..b667882 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -15,6 +15,7 @@
 // Keep this import first.
 import '../base/static_initializers';
 import '../gen/all_plugins';
+import '../gen/all_core_plugins';
 
 import {Draft} from 'immer';
 import m from 'mithril';
diff --git a/ui/src/frontend/keyboard_event_handler.ts b/ui/src/frontend/keyboard_event_handler.ts
index 59f1d95..8ca508c 100644
--- a/ui/src/frontend/keyboard_event_handler.ts
+++ b/ui/src/frontend/keyboard_event_handler.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {exists} from '../base/utils';
 import {Actions} from '../common/actions';
 import {Area, getLegacySelection} from '../common/state';
 
@@ -119,7 +120,7 @@
 export function lockSliceSpan(persistent = false) {
   const range = globals.findTimeRangeOfSelection();
   const currentSelection = getLegacySelection(globals.state);
-  if (range.start !== -1n && range.end !== -1n && currentSelection !== null) {
+  if (exists(range) && currentSelection !== null) {
     const tracks = currentSelection.trackKey ? [currentSelection.trackKey] : [];
     const area: Area = {start: range.start, end: range.end, tracks};
     globals.dispatch(Actions.markArea({area, persistent}));
@@ -131,7 +132,7 @@
   if (selection === null) return;
 
   const range = globals.findTimeRangeOfSelection();
-  if (range.start !== -1n && range.end !== -1n) {
+  if (exists(range)) {
     focusHorizontalRange(range.start, range.end);
   }
 
diff --git a/ui/src/frontend/named_slice_track.ts b/ui/src/frontend/named_slice_track.ts
index 5eff6af..9d0eb75 100644
--- a/ui/src/frontend/named_slice_track.ts
+++ b/ui/src/frontend/named_slice_track.ts
@@ -81,9 +81,6 @@
         kind: 'CHROME_SLICE',
         id: args.slice.id,
         trackKey: this.trackKey,
-        // |table| here can be either 'slice' or 'annotation'. The
-        // AnnotationSliceTrack overrides the onSliceClick and sets this to
-        // 'annotation'
         table: 'slice',
       },
       {
diff --git a/ui/src/frontend/query_result_tab.ts b/ui/src/frontend/query_result_tab.ts
index 23b5148..7fe7b2d 100644
--- a/ui/src/frontend/query_result_tab.ts
+++ b/ui/src/frontend/query_result_tab.ts
@@ -22,7 +22,7 @@
 import {
   AddDebugTrackMenu,
   uuidToViewName,
-} from '../tracks/debug/add_debug_track_menu';
+} from '../core_plugins/debug/add_debug_track_menu';
 import {Button} from '../widgets/button';
 import {PopupMenu2} from '../widgets/menu';
 import {PopupPosition} from '../widgets/popup';
diff --git a/ui/src/frontend/rpc_http_dialog.ts b/ui/src/frontend/rpc_http_dialog.ts
index 9095023..13db6b6 100644
--- a/ui/src/frontend/rpc_http_dialog.ts
+++ b/ui/src/frontend/rpc_http_dialog.ts
@@ -130,7 +130,7 @@
 //    |  |                                       |No             |Yes        |
 //    |  |                                       |  +---------------------+  |
 //    |  |                                       |  | Dialog: Preloaded?  |  |
-//    |  |                                       +--+ YES, use loaded trace  |
+//    |  |                                       |  + YES, use loaded trace  |
 //    |  |                                 +--------| YES, but reset state|  |
 //    |  |  +---------------------------------------| NO, Use builtin Wasm|  |
 //    |  |  |                              |     |  +---------------------+  |
diff --git a/ui/src/frontend/simple_slice_track.ts b/ui/src/frontend/simple_slice_track.ts
index 6d287b3..f46812f 100644
--- a/ui/src/frontend/simple_slice_track.ts
+++ b/ui/src/frontend/simple_slice_track.ts
@@ -17,12 +17,12 @@
   CustomSqlDetailsPanelConfig,
   CustomSqlTableDefConfig,
   CustomSqlTableSliceTrack,
-} from '../tracks/custom_sql_table_slices';
+} from '../core_plugins/custom_sql_table_slices';
 import {NamedSliceTrackTypes} from './named_slice_track';
 import {ARG_PREFIX, SliceColumns, SqlDataSource} from './debug_tracks';
 import {uuidv4Sql} from '../base/uuid';
 import {DisposableCallback} from '../base/disposable';
-import {DebugSliceDetailsTab} from '../tracks/debug/details_tab';
+import {DebugSliceDetailsTab} from '../core_plugins/debug/details_tab';
 
 export interface SimpleSliceTrackConfig {
   data: SqlDataSource;
diff --git a/ui/src/frontend/slice_details_panel.ts b/ui/src/frontend/slice_details_panel.ts
index fb5d3a2..3887577 100644
--- a/ui/src/frontend/slice_details_panel.ts
+++ b/ui/src/frontend/slice_details_panel.ts
@@ -16,7 +16,7 @@
 
 import {Actions} from '../common/actions';
 import {translateState} from '../common/thread_state';
-import {THREAD_STATE_TRACK_KIND} from '../tracks/thread_state';
+import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
 import {Anchor} from '../widgets/anchor';
 import {DetailsShell} from '../widgets/details_shell';
 import {GridLayout} from '../widgets/grid_layout';
diff --git a/ui/src/frontend/slice_track.ts b/ui/src/frontend/slice_track.ts
deleted file mode 100644
index e12cd73..0000000
--- a/ui/src/frontend/slice_track.ts
+++ /dev/null
@@ -1,404 +0,0 @@
-// Copyright (C) 2023 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.
-
-import {duration, Time, time} from '../base/time';
-import {Actions} from '../common/actions';
-import {cropText, drawIncompleteSlice} from '../common/canvas_utils';
-import {getColorForSlice} from '../core/colorizer';
-import {HighPrecisionTime} from '../common/high_precision_time';
-import {TrackData} from '../common/track_data';
-import {TimelineFetcher} from '../common/track_helper';
-import {SliceRect, Track} from '../public';
-import {getLegacySelection} from '../common/state';
-
-import {CROP_INCOMPLETE_SLICE_FLAG} from './base_slice_track';
-import {checkerboardExcept} from './checkerboard';
-import {globals} from './globals';
-import {PanelSize} from './panel';
-
-export const SLICE_TRACK_KIND = 'ChromeSliceTrack';
-const SLICE_HEIGHT = 18;
-const TRACK_PADDING = 2;
-const CHEVRON_WIDTH_PX = 10;
-const HALF_CHEVRON_WIDTH_PX = CHEVRON_WIDTH_PX / 2;
-const INCOMPLETE_SLICE_WIDTH_PX = 20;
-
-export interface SliceData extends TrackData {
-  // Slices are stored in a columnar fashion.
-  strings: string[];
-  sliceIds: Float64Array;
-  starts: BigInt64Array;
-  ends: BigInt64Array;
-  depths: Uint16Array;
-  titles: Uint16Array; // Index into strings.
-  colors?: Uint16Array; // Index into strings.
-  isInstant: Uint16Array;
-  isIncomplete: Uint16Array;
-  cpuTimeRatio?: Float64Array;
-}
-
-// Track base class which handles rendering slices in a generic way.
-// This is the old way of rendering slices - i.e. "track v1" format  - and
-// exists as a patch to allow old tracks to be converted to controller-less
-// tracks before they are ported to v2.
-// Slice tracks should extend this class and implement the abstract methods,
-// notably onBoundsChange().
-// Note: This class is deprecated and should not be used for new tracks. Use
-// |BaseSliceTrack| instead.
-export abstract class SliceTrackLEGACY implements Track {
-  private fetcher = new TimelineFetcher(this.onBoundsChange.bind(this));
-
-  constructor(
-    private maxDepth: number,
-    protected trackKey: string,
-    private tableName: string,
-    private namespace?: string,
-  ) {}
-
-  async onUpdate(): Promise<void> {
-    await this.fetcher.requestDataForCurrentTime();
-  }
-
-  async onDestroy(): Promise<void> {
-    this.fetcher.dispose();
-  }
-
-  abstract onBoundsChange(
-    start: time,
-    end: time,
-    resolution: duration,
-  ): Promise<SliceData>;
-
-  protected namespaceTable(tableName: string = this.tableName): string {
-    if (this.namespace) {
-      return this.namespace + '_' + tableName;
-    } else {
-      return tableName;
-    }
-  }
-
-  private hoveredTitleId = -1;
-
-  // Font used to render the slice name on the current track.
-  protected getFont() {
-    return '12px Roboto Condensed';
-  }
-
-  render(ctx: CanvasRenderingContext2D, size: PanelSize): void {
-    // TODO: fonts and colors should come from the CSS and not hardcoded here.
-    const data = this.fetcher.data;
-    if (data === undefined) return; // Can't possibly draw anything.
-
-    const {visibleTimeSpan, visibleTimeScale} = globals.timeline;
-
-    // If the cached trace slices don't fully cover the visible time range,
-    // show a gray rectangle with a "Loading..." label.
-    checkerboardExcept(
-      ctx,
-      this.getHeight(),
-      0,
-      size.width,
-      visibleTimeScale.timeToPx(data.start),
-      visibleTimeScale.timeToPx(data.end),
-    );
-
-    ctx.textAlign = 'center';
-
-    // measuretext is expensive so we only use it once.
-    const charWidth = ctx.measureText('ACBDLqsdfg').width / 10;
-
-    // The draw of the rect on the selected slice must happen after the other
-    // drawings, otherwise it would result under another rect.
-    let drawRectOnSelected = () => {};
-
-    for (let i = 0; i < data.starts.length; i++) {
-      const tStart = Time.fromRaw(data.starts[i]);
-      let tEnd = Time.fromRaw(data.ends[i]);
-      const depth = data.depths[i];
-      const titleId = data.titles[i];
-      const sliceId = data.sliceIds[i];
-      const isInstant = data.isInstant[i];
-      const isIncomplete = data.isIncomplete[i];
-      const title = data.strings[titleId];
-      const colorOverride = data.colors && data.strings[data.colors[i]];
-      if (isIncomplete) {
-        // incomplete slice
-        // TODO(stevegolton): This isn't exactly equivalent, ideally we should
-        // choose tEnd once we've converted to screen space coords.
-        tEnd = this.getEndTimeIfInComplete(tStart);
-      }
-
-      if (!visibleTimeSpan.intersects(tStart, tEnd)) {
-        continue;
-      }
-
-      const pxEnd = size.width;
-      const left = Math.max(visibleTimeScale.timeToPx(tStart), 0);
-      const right = Math.min(visibleTimeScale.timeToPx(tEnd), pxEnd);
-
-      const rect = {
-        left,
-        width: Math.max(right - left, 1),
-        top: TRACK_PADDING + depth * SLICE_HEIGHT,
-        height: SLICE_HEIGHT,
-      };
-
-      const currentSelection = getLegacySelection(globals.state);
-      const isSelected =
-        currentSelection &&
-        currentSelection.kind === 'CHROME_SLICE' &&
-        currentSelection.id !== undefined &&
-        currentSelection.id === sliceId;
-
-      const highlighted =
-        titleId === this.hoveredTitleId ||
-        globals.state.highlightedSliceId === sliceId;
-
-      const hasFocus = highlighted || isSelected;
-      const colorScheme = getColorForSlice(title);
-      const colorObj = hasFocus ? colorScheme.variant : colorScheme.base;
-      const textColor = hasFocus
-        ? colorScheme.textVariant
-        : colorScheme.textBase;
-
-      let color: string;
-      if (colorOverride === undefined) {
-        color = colorObj.cssString;
-      } else {
-        color = colorOverride;
-      }
-      ctx.fillStyle = color;
-
-      // We draw instant events as upward facing chevrons starting at A:
-      //     A
-      //    ###
-      //   ##C##
-      //  ##   ##
-      // D       B
-      // Then B, C, D and back to A:
-      if (isInstant) {
-        if (isSelected) {
-          drawRectOnSelected = () => {
-            ctx.save();
-            ctx.translate(rect.left, rect.top);
-
-            // Draw a rectangle around the selected slice
-            ctx.strokeStyle = colorObj.setHSL({s: 100, l: 10}).cssString;
-            ctx.beginPath();
-            ctx.lineWidth = 3;
-            ctx.strokeRect(
-              -HALF_CHEVRON_WIDTH_PX,
-              0,
-              CHEVRON_WIDTH_PX,
-              SLICE_HEIGHT,
-            );
-            ctx.closePath();
-
-            // Draw inner chevron as interior
-            ctx.fillStyle = color;
-            this.drawChevron(ctx);
-
-            ctx.restore();
-          };
-        } else {
-          ctx.save();
-          ctx.translate(rect.left, rect.top);
-          this.drawChevron(ctx);
-          ctx.restore();
-        }
-        continue;
-      }
-
-      if (isIncomplete && rect.width > SLICE_HEIGHT / 4) {
-        drawIncompleteSlice(
-          ctx,
-          rect.left,
-          rect.top,
-          rect.width,
-          SLICE_HEIGHT,
-          !CROP_INCOMPLETE_SLICE_FLAG.get(),
-        );
-      } else if (
-        data.cpuTimeRatio !== undefined &&
-        data.cpuTimeRatio[i] < 1 - 1e-9
-      ) {
-        // We draw two rectangles, representing the ratio between wall time and
-        // time spent on cpu.
-        const cpuTimeRatio = data.cpuTimeRatio![i];
-        const firstPartWidth = rect.width * cpuTimeRatio;
-        const secondPartWidth = rect.width * (1 - cpuTimeRatio);
-        ctx.fillRect(rect.left, rect.top, rect.width, SLICE_HEIGHT);
-        ctx.fillStyle = '#FFFFFF50';
-        ctx.fillRect(
-          rect.left + firstPartWidth,
-          rect.top,
-          secondPartWidth,
-          SLICE_HEIGHT,
-        );
-      } else {
-        ctx.fillRect(rect.left, rect.top, rect.width, SLICE_HEIGHT);
-      }
-
-      // Selected case
-      if (isSelected) {
-        drawRectOnSelected = () => {
-          ctx.strokeStyle = colorObj.setHSL({s: 100, l: 10}).cssString;
-          ctx.beginPath();
-          ctx.lineWidth = 3;
-          ctx.strokeRect(
-            rect.left,
-            rect.top - 1.5,
-            rect.width,
-            SLICE_HEIGHT + 3,
-          );
-          ctx.closePath();
-        };
-      }
-
-      // Don't render text when we have less than 5px to play with.
-      if (rect.width >= 5) {
-        ctx.fillStyle = textColor.cssString;
-        const displayText = cropText(title, charWidth, rect.width);
-        const rectXCenter = rect.left + rect.width / 2;
-        ctx.textBaseline = 'middle';
-        ctx.font = this.getFont();
-        ctx.fillText(displayText, rectXCenter, rect.top + SLICE_HEIGHT / 2);
-      }
-    }
-    drawRectOnSelected();
-  }
-
-  drawChevron(ctx: CanvasRenderingContext2D) {
-    // Draw a chevron at a fixed location and size. Should be used with
-    // ctx.translate and ctx.scale to alter location and size.
-    ctx.beginPath();
-    ctx.moveTo(0, 0);
-    ctx.lineTo(HALF_CHEVRON_WIDTH_PX, SLICE_HEIGHT);
-    ctx.lineTo(0, SLICE_HEIGHT - HALF_CHEVRON_WIDTH_PX);
-    ctx.lineTo(-HALF_CHEVRON_WIDTH_PX, SLICE_HEIGHT);
-    ctx.lineTo(0, 0);
-    ctx.fill();
-  }
-
-  getSliceIndex({x, y}: {x: number; y: number}): number | void {
-    const data = this.fetcher.data;
-    if (data === undefined) return;
-    const {visibleTimeScale: timeScale} = globals.timeline;
-    if (y < TRACK_PADDING) return;
-    const instantWidthTime = timeScale.pxDeltaToDuration(HALF_CHEVRON_WIDTH_PX);
-    const t = timeScale.pxToHpTime(x);
-    const depth = Math.floor((y - TRACK_PADDING) / SLICE_HEIGHT);
-
-    for (let i = 0; i < data.starts.length; i++) {
-      if (depth !== data.depths[i]) {
-        continue;
-      }
-      const start = Time.fromRaw(data.starts[i]);
-      const tStart = HighPrecisionTime.fromTime(start);
-      if (data.isInstant[i]) {
-        if (tStart.sub(t).abs().lt(instantWidthTime)) {
-          return i;
-        }
-      } else {
-        const end = Time.fromRaw(data.ends[i]);
-        let tEnd = HighPrecisionTime.fromTime(end);
-        if (data.isIncomplete[i]) {
-          const endTime = this.getEndTimeIfInComplete(start);
-          tEnd = HighPrecisionTime.fromTime(endTime);
-        }
-        if (tStart.lte(t) && t.lte(tEnd)) {
-          return i;
-        }
-      }
-    }
-  }
-
-  getEndTimeIfInComplete(start: time): time {
-    const {visibleTimeScale, visibleWindowTime} = globals.timeline;
-
-    let end = visibleWindowTime.end.toTime('ceil');
-    if (CROP_INCOMPLETE_SLICE_FLAG.get()) {
-      const widthTime = visibleTimeScale
-        .pxDeltaToDuration(INCOMPLETE_SLICE_WIDTH_PX)
-        .toTime();
-      end = Time.add(start, widthTime);
-    }
-
-    return end;
-  }
-
-  onMouseMove({x, y}: {x: number; y: number}) {
-    this.hoveredTitleId = -1;
-    globals.dispatch(Actions.setHighlightedSliceId({sliceId: -1}));
-    const sliceIndex = this.getSliceIndex({x, y});
-    if (sliceIndex === undefined) return;
-    const data = this.fetcher.data;
-    if (data === undefined) return;
-    this.hoveredTitleId = data.titles[sliceIndex];
-    const sliceId = data.sliceIds[sliceIndex];
-    globals.dispatch(Actions.setHighlightedSliceId({sliceId}));
-  }
-
-  onMouseOut() {
-    this.hoveredTitleId = -1;
-    globals.dispatch(Actions.setHighlightedSliceId({sliceId: -1}));
-  }
-
-  onMouseClick({x, y}: {x: number; y: number}): boolean {
-    const sliceIndex = this.getSliceIndex({x, y});
-    if (sliceIndex === undefined) return false;
-    const data = this.fetcher.data;
-    if (data === undefined) return false;
-    const sliceId = data.sliceIds[sliceIndex];
-    if (sliceId !== undefined && sliceId !== -1) {
-      globals.setLegacySelection(
-        {
-          kind: 'CHROME_SLICE',
-          id: sliceId,
-          trackKey: this.trackKey,
-          table: this.namespace,
-        },
-        {
-          clearSearch: true,
-          pendingScrollId: undefined,
-          switchToCurrentSelectionTab: true,
-        },
-      );
-      return true;
-    }
-    return false;
-  }
-
-  getHeight() {
-    return SLICE_HEIGHT * (this.maxDepth + 1) + 2 * TRACK_PADDING;
-  }
-
-  getSliceRect(tStart: time, tEnd: time, depth: number): SliceRect | undefined {
-    const {windowSpan, visibleTimeScale, visibleTimeSpan} = globals.timeline;
-
-    const pxEnd = windowSpan.end;
-    const left = Math.max(visibleTimeScale.timeToPx(tStart), 0);
-    const right = Math.min(visibleTimeScale.timeToPx(tEnd), pxEnd);
-
-    const visible = visibleTimeSpan.intersects(tStart, tEnd);
-
-    return {
-      left,
-      width: Math.max(right - left, 1),
-      top: TRACK_PADDING + depth * SLICE_HEIGHT,
-      height: SLICE_HEIGHT,
-      visible,
-    };
-  }
-}
diff --git a/ui/src/frontend/sql_table/tab.ts b/ui/src/frontend/sql_table/tab.ts
index ea173a0..4267331 100644
--- a/ui/src/frontend/sql_table/tab.ts
+++ b/ui/src/frontend/sql_table/tab.ts
@@ -17,7 +17,7 @@
 import {copyToClipboard} from '../../base/clipboard';
 import {Icons} from '../../base/semantic_icons';
 import {exists} from '../../base/utils';
-import {AddDebugTrackMenu} from '../../tracks/debug/add_debug_track_menu';
+import {AddDebugTrackMenu} from '../../core_plugins/debug/add_debug_track_menu';
 import {Button} from '../../widgets/button';
 import {DetailsShell} from '../../widgets/details_shell';
 import {Popup, PopupPosition} from '../../widgets/popup';
diff --git a/ui/src/frontend/thread_state.ts b/ui/src/frontend/thread_state.ts
index 8b0d520..e912a7e 100644
--- a/ui/src/frontend/thread_state.ts
+++ b/ui/src/frontend/thread_state.ts
@@ -21,8 +21,8 @@
 import {translateState} from '../common/thread_state';
 import {EngineProxy} from '../trace_processor/engine';
 import {LONG, NUM, NUM_NULL, STR_NULL} from '../trace_processor/query_result';
-import {CPU_SLICE_TRACK_KIND} from '../tracks/cpu_slices';
-import {THREAD_STATE_TRACK_KIND} from '../tracks/thread_state';
+import {CPU_SLICE_TRACK_KIND} from '../core_plugins/cpu_slices';
+import {THREAD_STATE_TRACK_KIND} from '../core_plugins/thread_state';
 import {Anchor} from '../widgets/anchor';
 
 import {globals} from './globals';
diff --git a/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts b/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
index 414a662..2a22aff 100644
--- a/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidLongBatteryTracing/index.ts
@@ -51,6 +51,33 @@
       end as name
   from diff where keep is null or keep`;
 
+const RADIO_TRANSPORT_TYPE = `
+  create or replace perfetto view radio_transport_data_conn as
+  select ts, safe_dur AS dur, value_name as data_conn, value AS data_conn_val
+  from android_battery_stats_state
+  where track_name = "battery_stats.data_conn";
+
+  create or replace perfetto view radio_transport_nr_state as
+  select ts, safe_dur AS dur, value AS nr_state_val
+  from android_battery_stats_state
+  where track_name = "battery_stats.nr_state";
+
+  drop table if exists radio_transport_join;
+  create virtual table radio_transport_join
+  using span_left_join(radio_transport_data_conn, radio_transport_nr_state);
+
+  create or replace perfetto view radio_transport as
+  select
+    ts, dur,
+    case data_conn_val
+      -- On LTE with NR connected is 5G NSA.
+      when 13 then iif(nr_state_val = 3, '5G (NSA)', data_conn)
+      -- On NR with NR state present, is 5G SA.
+      when 20 then iif(nr_state_val is null, '5G (SA or NSA)', '5G (SA)')
+      else data_conn
+    end as name
+  from radio_transport_join;`;
+
 const TETHERING = `
   with base as (
       select
@@ -1144,7 +1171,7 @@
     this.addSliceTrack(
       ctx,
       name,
-      `SELECT ts, dur, value_name AS name
+      `SELECT ts, safe_dur AS dur, value_name AS name
     FROM android_battery_stats_state
     WHERE track_name = "${track}"`,
       groupName,
@@ -1165,7 +1192,7 @@
     this.addSliceTrack(
       ctx,
       name,
-      `SELECT ts, dur, str_value AS name
+      `SELECT ts, safe_dur AS dur, str_value AS name
     FROM android_battery_stats_event_slices
     WHERE track_name = "${track}"`,
       groupName,
@@ -1201,7 +1228,7 @@
       'Device State: Long wakelocks',
       `SELECT
             ts - 60000000000 as ts,
-            dur + 60000000000 as dur,
+            safe_dur + 60000000000 as dur,
             str_value AS name,
             ifnull(
             (select package_name from package_list where uid = int_value % 100000),
@@ -1236,6 +1263,7 @@
 
     const e = ctx.engine;
     await e.query(NETWORK_SUMMARY);
+    await e.query(RADIO_TRANSPORT_TYPE);
 
     this.addSliceTrack(ctx, 'Default network', DEFAULT_NETWORK, groupName);
 
@@ -1295,12 +1323,11 @@
       groupName,
       features,
     );
-    this.addBatteryStatsState(
+    this.addSliceTrack(
       ctx,
       'Cellular connection',
-      'battery_stats.data_conn',
+      `select ts, dur, name from radio_transport`,
       groupName,
-      features,
     );
     this.addBatteryStatsState(
       ctx,
diff --git a/ui/src/plugins/org.kernel.LinuxKernelDevices/index.ts b/ui/src/plugins/org.kernel.LinuxKernelDevices/index.ts
index b9127f0..eca39b4 100644
--- a/ui/src/plugins/org.kernel.LinuxKernelDevices/index.ts
+++ b/ui/src/plugins/org.kernel.LinuxKernelDevices/index.ts
@@ -19,8 +19,8 @@
   PluginDescriptor,
   STR_NULL,
 } from '../../public';
-import {ASYNC_SLICE_TRACK_KIND} from '../../tracks/async_slices';
-import {AsyncSliceTrackV2} from '../../tracks/async_slices/async_slice_track_v2';
+import {ASYNC_SLICE_TRACK_KIND} from '../../core_plugins/async_slices';
+import {AsyncSliceTrackV2} from '../../core_plugins/async_slices/async_slice_track_v2';
 
 // This plugin renders visualizations of runtime power state transitions for
 // Linux kernel devices (devices managed by Linux drivers).
diff --git a/ui/src/tracks/chrome_slices/generic_slice_track.ts b/ui/src/tracks/chrome_slices/generic_slice_track.ts
deleted file mode 100644
index 1e817a8..0000000
--- a/ui/src/tracks/chrome_slices/generic_slice_track.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (C) 2021 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.
-
-import {
-  NamedSliceTrack,
-  NamedSliceTrackTypes,
-} from '../../frontend/named_slice_track';
-import {NewTrackArgs} from '../../frontend/track';
-
-export class GenericSliceTrack extends NamedSliceTrack<NamedSliceTrackTypes> {
-  constructor(args: NewTrackArgs, private sqlTrackId: number) {
-    super(args);
-  }
-
-  getSqlSource(): string {
-    return `select ts, dur, id, depth, ifnull(name, '') as name
-    from slice where track_id = ${this.sqlTrackId}`;
-  }
-}
diff --git a/ui/src/tracks/chrome_slices/index.ts b/ui/src/tracks/chrome_slices/index.ts
deleted file mode 100644
index cae2557..0000000
--- a/ui/src/tracks/chrome_slices/index.ts
+++ /dev/null
@@ -1,312 +0,0 @@
-// Copyright (C) 2021 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.
-
-import {BigintMath as BIMath} from '../../base/bigint_math';
-import {clamp} from '../../base/math_utils';
-import {Duration, duration, time} from '../../base/time';
-import {uuidv4} from '../../base/uuid';
-import {ChromeSliceDetailsTab} from '../../frontend/chrome_slice_details_tab';
-import {
-  NAMED_ROW,
-  NamedSliceTrack,
-  NamedSliceTrackTypes,
-} from '../../frontend/named_slice_track';
-import {SLICE_LAYOUT_FIT_CONTENT_DEFAULTS} from '../../frontend/slice_layout';
-import {SliceData, SliceTrackLEGACY} from '../../frontend/slice_track';
-import {NewTrackArgs} from '../../frontend/track';
-import {
-  BottomTabToSCSAdapter,
-  EngineProxy,
-  Plugin,
-  PluginContextTrace,
-  PluginDescriptor,
-} from '../../public';
-import {getTrackName} from '../../public/utils';
-import {
-  LONG,
-  LONG_NULL,
-  NUM,
-  NUM_NULL,
-  STR,
-  STR_NULL,
-} from '../../trace_processor/query_result';
-
-export const SLICE_TRACK_KIND = 'ChromeSliceTrack';
-
-export class ChromeSliceTrack extends SliceTrackLEGACY {
-  private maxDurNs: duration = 0n;
-
-  constructor(
-    protected engine: EngineProxy,
-    maxDepth: number,
-    trackKey: string,
-    private trackId: number,
-    namespace?: string,
-  ) {
-    super(maxDepth, trackKey, 'slice', namespace);
-  }
-
-  async onBoundsChange(
-    start: time,
-    end: time,
-    resolution: duration,
-  ): Promise<SliceData> {
-    const tableName = this.namespaceTable('slice');
-
-    if (this.maxDurNs === Duration.ZERO) {
-      const query = `
-          SELECT max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur))
-          AS maxDur FROM ${tableName} WHERE track_id = ${this.trackId}`;
-      const queryRes = await this.engine.query(query);
-      this.maxDurNs = queryRes.firstRow({maxDur: LONG_NULL}).maxDur ?? 0n;
-    }
-
-    const query = `
-      SELECT
-        (ts + ${resolution / 2n}) / ${resolution} * ${resolution} as tsq,
-        ts,
-        max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur)) as dur,
-        depth,
-        id as sliceId,
-        ifnull(name, '[null]') as name,
-        dur = 0 as isInstant,
-        dur = -1 as isIncomplete,
-        thread_dur as threadDur
-      FROM ${tableName}
-      WHERE track_id = ${this.trackId} AND
-        ts >= (${start - this.maxDurNs}) AND
-        ts <= ${end}
-      GROUP BY depth, tsq`;
-    const queryRes = await this.engine.query(query);
-
-    const numRows = queryRes.numRows();
-    const slices: SliceData = {
-      start,
-      end,
-      resolution,
-      length: numRows,
-      strings: [],
-      sliceIds: new Float64Array(numRows),
-      starts: new BigInt64Array(numRows),
-      ends: new BigInt64Array(numRows),
-      depths: new Uint16Array(numRows),
-      titles: new Uint16Array(numRows),
-      isInstant: new Uint16Array(numRows),
-      isIncomplete: new Uint16Array(numRows),
-      cpuTimeRatio: new Float64Array(numRows),
-    };
-
-    const stringIndexes = new Map<string, number>();
-    function internString(str: string) {
-      let idx = stringIndexes.get(str);
-      if (idx !== undefined) return idx;
-      idx = slices.strings.length;
-      slices.strings.push(str);
-      stringIndexes.set(str, idx);
-      return idx;
-    }
-
-    const it = queryRes.iter({
-      tsq: LONG,
-      ts: LONG,
-      dur: LONG,
-      depth: NUM,
-      sliceId: NUM,
-      name: STR,
-      isInstant: NUM,
-      isIncomplete: NUM,
-      threadDur: LONG_NULL,
-    });
-    for (let row = 0; it.valid(); it.next(), row++) {
-      const startQ = it.tsq;
-      const start = it.ts;
-      const dur = it.dur;
-      const end = start + dur;
-      const minEnd = startQ + resolution;
-      const endQ = BIMath.max(BIMath.quant(end, resolution), minEnd);
-
-      slices.starts[row] = startQ;
-      slices.ends[row] = endQ;
-      slices.depths[row] = it.depth;
-      slices.sliceIds[row] = it.sliceId;
-      slices.titles[row] = internString(it.name);
-      slices.isInstant[row] = it.isInstant;
-      slices.isIncomplete[row] = it.isIncomplete;
-
-      let cpuTimeRatio = 1;
-      if (!it.isInstant && !it.isIncomplete && it.threadDur !== null) {
-        // Rounding the CPU time ratio to two decimal places and ensuring
-        // it is less than or equal to one, incase the thread duration exceeds
-        // the total duration.
-        cpuTimeRatio = Math.min(
-          Math.round(BIMath.ratio(it.threadDur, it.dur) * 100) / 100,
-          1,
-        );
-      }
-      slices.cpuTimeRatio![row] = cpuTimeRatio;
-    }
-    return slices;
-  }
-}
-
-export const CHROME_SLICE_ROW = {
-  // Base columns (tsq, ts, dur, id, depth).
-  ...NAMED_ROW,
-
-  // Chrome-specific columns.
-  threadDur: LONG_NULL,
-};
-export type ChromeSliceRow = typeof CHROME_SLICE_ROW;
-
-export interface ChromeSliceTrackTypes extends NamedSliceTrackTypes {
-  row: ChromeSliceRow;
-}
-
-export class ChromeSliceTrackV2 extends NamedSliceTrack<ChromeSliceTrackTypes> {
-  constructor(args: NewTrackArgs, private trackId: number, maxDepth: number) {
-    super(args);
-    this.sliceLayout = {
-      ...SLICE_LAYOUT_FIT_CONTENT_DEFAULTS,
-      depthGuess: maxDepth,
-    };
-  }
-
-  // This is used by the base class to call iter().
-  getRowSpec() {
-    return CHROME_SLICE_ROW;
-  }
-
-  getSqlSource(): string {
-    return `select
-      ts,
-      dur,
-      id,
-      depth,
-      ifnull(name, '') as name,
-      thread_dur as threadDur
-    from slice
-    where track_id = ${this.trackId}`;
-  }
-
-  // Converts a SQL result row to an "Impl" Slice.
-  rowToSlice(
-    row: ChromeSliceTrackTypes['row'],
-  ): ChromeSliceTrackTypes['slice'] {
-    const namedSlice = super.rowToSlice(row);
-
-    if (row.dur > 0n && row.threadDur !== null) {
-      const fillRatio = clamp(BIMath.ratio(row.threadDur, row.dur), 0, 1);
-      return {...namedSlice, fillRatio};
-    } else {
-      return namedSlice;
-    }
-  }
-
-  onUpdatedSlices(slices: ChromeSliceTrackTypes['slice'][]) {
-    for (const slice of slices) {
-      slice.isHighlighted = slice === this.hoveredSlice;
-    }
-  }
-}
-
-class ChromeSlicesPlugin implements Plugin {
-  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
-    const {engine} = ctx;
-    const result = await engine.query(`
-        with max_depth_materialized as (
-          select track_id, max(depth) as maxDepth
-          from slice
-          group by track_id
-        )
-        select
-          thread_track.utid as utid,
-          thread_track.id as trackId,
-          thread_track.name as trackName,
-          EXTRACT_ARG(thread_track.source_arg_set_id,
-                      'is_root_in_scope') as isDefaultTrackForScope,
-          tid,
-          thread.name as threadName,
-          maxDepth,
-          thread.upid as upid
-        from thread_track
-        join thread using(utid)
-        join max_depth_materialized mdd on mdd.track_id = thread_track.id
-  `);
-
-    const it = result.iter({
-      utid: NUM,
-      trackId: NUM,
-      trackName: STR_NULL,
-      isDefaultTrackForScope: NUM_NULL,
-      tid: NUM_NULL,
-      threadName: STR_NULL,
-      maxDepth: NUM,
-      upid: NUM_NULL,
-    });
-
-    for (; it.valid(); it.next()) {
-      const utid = it.utid;
-      const trackId = it.trackId;
-      const trackName = it.trackName;
-      const tid = it.tid;
-      const threadName = it.threadName;
-      const maxDepth = it.maxDepth;
-
-      const displayName = getTrackName({
-        name: trackName,
-        utid,
-        tid,
-        threadName,
-        kind: 'Slices',
-      });
-
-      ctx.registerTrack({
-        uri: `perfetto.ChromeSlices#${trackId}`,
-        displayName,
-        trackIds: [trackId],
-        kind: SLICE_TRACK_KIND,
-        trackFactory: ({trackKey}) => {
-          const newTrackArgs = {
-            engine: ctx.engine,
-            trackKey,
-          };
-          return new ChromeSliceTrackV2(newTrackArgs, trackId, maxDepth);
-        },
-      });
-    }
-
-    ctx.registerDetailsPanel(
-      new BottomTabToSCSAdapter({
-        tabFactory: (sel) => {
-          if (sel.kind !== 'CHROME_SLICE') {
-            return undefined;
-          }
-          return new ChromeSliceDetailsTab({
-            config: {
-              table: sel.table ?? 'slice',
-              id: sel.id,
-            },
-            engine: ctx.engine,
-            uuid: uuidv4(),
-          });
-        },
-      }),
-    );
-  }
-}
-
-export const plugin: PluginDescriptor = {
-  pluginId: 'perfetto.ChromeSlices',
-  plugin: ChromeSlicesPlugin,
-};
diff --git a/ui/src/tracks/visualised_args/index.ts b/ui/src/tracks/visualised_args/index.ts
deleted file mode 100644
index 9e34de3..0000000
--- a/ui/src/tracks/visualised_args/index.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2022 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.
-
-import m from 'mithril';
-import {v4 as uuidv4} from 'uuid';
-
-import {Actions} from '../../common/actions';
-import {globals} from '../../frontend/globals';
-import {
-  EngineProxy,
-  Plugin,
-  PluginContextTrace,
-  PluginDescriptor,
-  TrackContext,
-} from '../../public';
-import {ChromeSliceTrack} from '../chrome_slices';
-import {
-  VISUALISED_ARGS_SLICE_TRACK_URI,
-  VisualisedArgsState,
-} from '../../frontend/visualized_args_tracks';
-import {Button} from '../../widgets/button';
-import {Icons} from '../../base/semantic_icons';
-
-export class VisualisedArgsTrack extends ChromeSliceTrack {
-  private helperViewName: string;
-
-  constructor(
-    engine: EngineProxy,
-    maxDepth: number,
-    trackKey: string,
-    trackId: number,
-    private argName: string,
-  ) {
-    const uuid = uuidv4();
-    const namespace = `__arg_visualisation_helper_${argName}_${uuid}`;
-    const escapedNamespace = namespace.replace(/[^a-zA-Z]/g, '_');
-    super(engine, maxDepth, trackKey, trackId, escapedNamespace);
-    this.helperViewName = `${escapedNamespace}_slice`;
-  }
-
-  async onCreate(_ctx: TrackContext): Promise<void> {
-    // Create the helper view - just one which is relevant to this slice
-    await this.engine.query(`
-          create view ${this.helperViewName} as
-          with slice_with_arg as (
-            select
-              slice.id,
-              slice.track_id,
-              slice.ts,
-              slice.dur,
-              slice.thread_dur,
-              NULL as cat,
-              args.display_value as name
-            from slice
-            join args using (arg_set_id)
-            where args.key='${this.argName}'
-          )
-          select
-            *,
-            (select count()
-            from ancestor_slice(s1.id) s2
-            join slice_with_arg s3 on s2.id=s3.id
-            ) as depth
-          from slice_with_arg s1
-          order by id;
-      `);
-  }
-
-  async onDestroy(): Promise<void> {
-    if (this.engine.isAlive) {
-      await this.engine.query(`drop view ${this.helperViewName}`);
-    }
-  }
-
-  getFont() {
-    return 'italic 11px Roboto';
-  }
-
-  getTrackShellButtons(): m.Children {
-    return m(Button, {
-      onclick: () => {
-        // This behavior differs to the original behavior a little.
-        // Originally, hitting the close button on a single track removed ALL
-        // tracks with this argName, whereas this one only closes the single
-        // track.
-        // This will be easily fixable once we transition to using dynamic
-        // tracks instead of this "initial state" approach to add these tracks.
-        globals.dispatch(Actions.removeTracks({trackKeys: [this.trackKey]}));
-      },
-      icon: Icons.Close,
-      title: 'Close',
-      compact: true,
-    });
-  }
-}
-
-class VisualisedArgsPlugin implements Plugin {
-  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
-    ctx.registerTrack({
-      uri: VISUALISED_ARGS_SLICE_TRACK_URI,
-      tags: {
-        metric: true, // TODO(stevegolton): Is this track really a metric?
-      },
-      trackFactory: (trackCtx) => {
-        // TODO(stevegolton): Validate params properly. Note, this is no
-        // worse than the situation we had before with track config.
-        const params = trackCtx.params as VisualisedArgsState;
-        return new VisualisedArgsTrack(
-          ctx.engine,
-          params.maxDepth,
-          trackCtx.trackKey,
-          params.trackId,
-          params.argName,
-        );
-      },
-    });
-  }
-}
-
-export const plugin: PluginDescriptor = {
-  pluginId: 'perfetto.VisualisedArgs',
-  plugin: VisualisedArgsPlugin,
-};