ui: Actual & Expected frame tracks

Change-Id: I7a69276e108868326c394a342d34e603fd3c6646
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 7ddd662..8d49590 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -17,9 +17,13 @@
 import {assertExists} from '../base/logging';
 import {randomColor} from '../common/colorizer';
 import {ConvertTrace, ConvertTraceToPprof} from '../controller/trace_converter';
+import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../tracks/actual_frames/common';
 import {ASYNC_SLICE_TRACK_KIND} from '../tracks/async_slices/common';
 import {COUNTER_TRACK_KIND} from '../tracks/counter/common';
 import {DEBUG_SLICE_TRACK_KIND} from '../tracks/debug_slices/common';
+import {
+  EXPECTED_FRAMES_SLICE_TRACK_KIND
+} from '../tracks/expected_frames/common';
 import {HEAP_PROFILE_TRACK_KIND} from '../tracks/heap_profile/common';
 import {
   PROCESS_SCHEDULING_TRACK_KIND
@@ -240,6 +244,8 @@
     const threadTrackOrder = [
       PROCESS_SCHEDULING_TRACK_KIND,
       PROCESS_SUMMARY_TRACK,
+      EXPECTED_FRAMES_SLICE_TRACK_KIND,
+      ACTUAL_FRAMES_SLICE_TRACK_KIND,
       HEAP_PROFILE_TRACK_KIND,
       COUNTER_TRACK_KIND,
       ASYNC_SLICE_TRACK_KIND
@@ -271,6 +277,7 @@
     }
   },
 
+
   updateAggregateSorting(
       state: StateDraft, args: {id: string, column: string}) {
     let prefs = state.aggregatePreferences[args.id];
diff --git a/ui/src/controller/track_decider.ts b/ui/src/controller/track_decider.ts
index d9b88ee..8d823b9 100644
--- a/ui/src/controller/track_decider.ts
+++ b/ui/src/controller/track_decider.ts
@@ -29,6 +29,7 @@
   STR_NULL,
 } from '../common/query_iterator';
 import {SCROLLING_TRACK_GROUP} from '../common/state';
+import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../tracks/actual_frames/common';
 import {ANDROID_LOGS_TRACK_KIND} from '../tracks/android_log/common';
 import {ASYNC_SLICE_TRACK_KIND} from '../tracks/async_slices/common';
 import {SLICE_TRACK_KIND} from '../tracks/chrome_slices/common';
@@ -36,6 +37,9 @@
 import {CPU_FREQ_TRACK_KIND} from '../tracks/cpu_freq/common';
 import {CPU_PROFILE_TRACK_KIND} from '../tracks/cpu_profile/common';
 import {CPU_SLICE_TRACK_KIND} from '../tracks/cpu_slices/common';
+import {
+  EXPECTED_FRAMES_SLICE_TRACK_KIND
+} from '../tracks/expected_frames/common';
 import {HEAP_PROFILE_TRACK_KIND} from '../tracks/heap_profile/common';
 import {
   PROCESS_SCHEDULING_TRACK_KIND
@@ -515,6 +519,7 @@
           process.pid as pid
         from process_track
         left join process using(upid)
+        where process_track.name not like "% Timeline"
         group by
           process_track.upid,
           process_track.name
@@ -563,6 +568,138 @@
   return tracks;
 }
 
+async function getActualFramesTracks(
+    engineId: string, engine: Engine, getUuid: GetUuid) {
+  const tracks: AddTrackArgs[] = [];
+  const query = await engine.query(`
+        select
+          upid,
+          trackName,
+          trackIds,
+          process.name as processName,
+          process.pid as pid
+        from (
+          select
+            process_track.upid as upid,
+            process_track.name as trackName,
+            group_concat(process_track.id) as trackIds
+          from process_track
+          where process_track.name like "Actual Timeline"
+          group by
+            process_track.upid,
+            process_track.name
+        ) left join process using(upid)
+  `);
+
+  const it = iter(
+      {
+        upid: NUM,
+        trackName: STR_NULL,
+        trackIds: STR,
+        processName: STR_NULL,
+        pid: NUM_NULL,
+      },
+      query);
+  for (let i = 0; it.valid(); ++i, it.next()) {
+    const row = it.row;
+    const upid = row.upid;
+    const trackName = row.trackName;
+    const rawTrackIds = row.trackIds;
+    const trackIds = rawTrackIds.split(',').map(v => Number(v));
+    const processName = row.processName;
+    const pid = row.pid;
+
+    const uuid = getUuid(0, upid);
+
+    // TODO(hjd): 1+N queries are bad in the track_decider
+    const depthResult = await engine.query(`
+      SELECT MAX(layout_depth) as max_depth
+      FROM experimental_slice_layout('${rawTrackIds}');
+    `);
+    const maxDepth = +depthResult.columns[0].longValues![0];
+
+    const kind = ACTUAL_FRAMES_SLICE_TRACK_KIND;
+    const name = getTrackName({name: trackName, upid, pid, processName, kind});
+    tracks.push({
+      engineId,
+      kind,
+      name,
+      trackGroup: uuid,
+      config: {
+        trackIds,
+        maxDepth,
+      }
+    });
+  }
+  return tracks;
+}
+
+async function getExpectedFramesTracks(
+    engineId: string, engine: Engine, getUuid: GetUuid) {
+  const tracks: AddTrackArgs[] = [];
+  const query = await engine.query(`
+        select
+          upid,
+          trackName,
+          trackIds,
+          process.name as processName,
+          process.pid as pid
+        from (
+          select
+            process_track.upid as upid,
+            process_track.name as trackName,
+            group_concat(process_track.id) as trackIds
+          from process_track
+          where process_track.name like "Expected Timeline"
+          group by
+            process_track.upid,
+            process_track.name
+        ) left join process using(upid)
+  `);
+
+  const it = iter(
+      {
+        upid: NUM,
+        trackName: STR_NULL,
+        trackIds: STR,
+        processName: STR_NULL,
+        pid: NUM_NULL,
+      },
+      query);
+  for (let i = 0; it.valid(); ++i, it.next()) {
+    const row = it.row;
+    const upid = row.upid;
+    const trackName = row.trackName;
+    const rawTrackIds = row.trackIds;
+    const trackIds = rawTrackIds.split(',').map(v => Number(v));
+    const processName = row.processName;
+    const pid = row.pid;
+
+    const uuid = getUuid(0, upid);
+
+    // TODO(hjd): 1+N queries are bad in the track_decider
+    const depthResult = await engine.query(`
+      SELECT MAX(layout_depth) as max_depth
+      FROM experimental_slice_layout('${rawTrackIds}');
+    `);
+    const maxDepth = +depthResult.columns[0].longValues![0];
+
+    const kind = EXPECTED_FRAMES_SLICE_TRACK_KIND;
+    const name = getTrackName({name: trackName, upid, pid, processName, kind});
+    tracks.push({
+      engineId,
+      kind,
+      name,
+      trackGroup: uuid,
+      config: {
+        trackIds,
+        maxDepth,
+      }
+    });
+  }
+  return tracks;
+}
+
 async function getThreadSliceTracks(
     engineId: string, engine: Engine, getUuid: GetUuid) {
   const tracks: AddTrackArgs[] = [];
@@ -868,6 +1005,8 @@
   extend(tracksToAdd, await getProcessCounterTracks(engineId, engine, getUuid));
   extend(
       tracksToAdd, await getProcessAsyncSliceTracks(engineId, engine, getUuid));
+  extend(tracksToAdd, await getActualFramesTracks(engineId, engine, getUuid));
+  extend(tracksToAdd, await getExpectedFramesTracks(engineId, engine, getUuid));
   extend(tracksToAdd, await getThreadCounterTracks(engineId, engine, getUuid));
   extend(tracksToAdd, await getThreadStateTracks(engineId, engine, getUuid));
   extend(tracksToAdd, await getThreadSliceTracks(engineId, engine, getUuid));
diff --git a/ui/src/tracks/actual_frames/common.ts b/ui/src/tracks/actual_frames/common.ts
new file mode 100644
index 0000000..a5b5a78
--- /dev/null
+++ b/ui/src/tracks/actual_frames/common.ts
@@ -0,0 +1,34 @@
+// 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 {TrackData} from '../../common/track_data';
+
+export const ACTUAL_FRAMES_SLICE_TRACK_KIND = 'ActualFramesSliceTrack';
+
+export interface Config {
+  maxDepth: number;
+  trackIds: number[];
+}
+
+export interface Data extends TrackData {
+  // Slices are stored in a columnar fashion. All fields have the same length.
+  strings: string[];
+  sliceIds: Float64Array;
+  starts: Float64Array;
+  ends: Float64Array;
+  depths: Uint16Array;
+  titles: Uint16Array;   // Index in |strings|.
+  colors?: Uint16Array;  // Index in |strings|.
+  isInstant: Uint16Array;
+  isIncomplete: Uint16Array;
+}
diff --git a/ui/src/tracks/actual_frames/controller.ts b/ui/src/tracks/actual_frames/controller.ts
new file mode 100644
index 0000000..ca5778e
--- /dev/null
+++ b/ui/src/tracks/actual_frames/controller.ts
@@ -0,0 +1,135 @@
+// 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 {assertTrue} from '../../base/logging';
+import {slowlyCountRows} from '../../common/query_iterator';
+import {fromNs, toNs} from '../../common/time';
+import {
+  TrackController,
+  trackControllerRegistry,
+} from '../../controller/track_controller';
+
+import {ACTUAL_FRAMES_SLICE_TRACK_KIND, Config, Data} from './common';
+
+const ERROR_COLOR = '#03A9F4';    // Blue 500
+const GOOD_COLOR = '#4CAF50';     // Green 500
+const WARNING_COLOR = '#FFEB3B';  // Yellow 500
+const BAD_COLOR = '#FF5722';      // Red 500
+
+class ActualFramesSliceTrackController extends TrackController<Config, Data> {
+  static readonly kind = ACTUAL_FRAMES_SLICE_TRACK_KIND;
+  private maxDurNs = 0;
+
+  async onBoundsChange(start: number, end: number, resolution: number):
+      Promise<Data> {
+    const startNs = toNs(start);
+    const endNs = toNs(end);
+
+    const pxSize = this.pxSize();
+
+    // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
+    // be an even number, so we can snap in the middle.
+    const bucketNs = Math.max(Math.round(resolution * 1e9 * pxSize / 2) * 2, 1);
+
+    if (this.maxDurNs === 0) {
+      const maxDurResult = await this.query(`
+        select max(dur)
+        from experimental_slice_layout
+        where filter_track_ids = '${this.config.trackIds.join(',')}'
+      `);
+      if (slowlyCountRows(maxDurResult) === 1) {
+        this.maxDurNs = maxDurResult.columns[0].longValues![0];
+      }
+    }
+
+    const rawResult = await this.query(`
+      SELECT
+        (s.ts + ${bucketNs / 2}) / ${bucketNs} * ${bucketNs} as tsq,
+        s.ts,
+        max(s.dur) as dur,
+        s.layout_depth,
+        s.name,
+        s.id,
+        s.dur = 0 as is_instant,
+        s.dur = -1 as is_incomplete,
+        CASE
+          WHEN afs.present_type = 'Dropped' THEN '${ERROR_COLOR}'
+          WHEN not afs.on_time_finish THEN '${BAD_COLOR}'
+          WHEN jank_type = 'None' THEN '${GOOD_COLOR}'
+          ELSE '${WARNING_COLOR}'
+        END as color
+      from experimental_slice_layout s
+      join actual_frame_timeline_slice afs using(id)
+      where
+        filter_track_ids = '${this.config.trackIds.join(',')}' and
+        s.ts >= ${startNs - this.maxDurNs} and
+        s.ts <= ${endNs}
+      group by tsq, s.layout_depth
+      order by tsq, s.layout_depth
+    `);
+
+    const numRows = slowlyCountRows(rawResult);
+    const slices: Data = {
+      start,
+      end,
+      resolution,
+      length: numRows,
+      strings: [],
+      sliceIds: new Float64Array(numRows),
+      starts: new Float64Array(numRows),
+      ends: new Float64Array(numRows),
+      depths: new Uint16Array(numRows),
+      titles: new Uint16Array(numRows),
+      colors: new Uint16Array(numRows),
+      isInstant: new Uint16Array(numRows),
+      isIncomplete: new Uint16Array(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 cols = rawResult.columns;
+    for (let row = 0; row < numRows; row++) {
+      const startNsQ = +cols[0].longValues![row];
+      const startNs = +cols[1].longValues![row];
+      const durNs = +cols[2].longValues![row];
+      const endNs = startNs + durNs;
+
+      let endNsQ = Math.floor((endNs + bucketNs / 2 - 1) / bucketNs) * bucketNs;
+      endNsQ = Math.max(endNsQ, startNsQ + bucketNs);
+
+      assertTrue(startNsQ !== endNsQ);
+
+      slices.starts[row] = fromNs(startNsQ);
+      slices.ends[row] = fromNs(endNsQ);
+      slices.depths[row] = +cols[3].longValues![row];
+      slices.titles[row] = internString(cols[4].stringValues![row]);
+      slices.colors![row] = internString(cols[8].stringValues![row]);
+      slices.sliceIds[row] = +cols[5].longValues![row];
+      slices.isInstant[row] = +cols[6].longValues![row];
+      slices.isIncomplete[row] = +cols[7].longValues![row];
+    }
+    return slices;
+  }
+}
+
+
+trackControllerRegistry.register(ActualFramesSliceTrackController);
diff --git a/ui/src/tracks/actual_frames/frontend.ts b/ui/src/tracks/actual_frames/frontend.ts
new file mode 100644
index 0000000..3c91598
--- /dev/null
+++ b/ui/src/tracks/actual_frames/frontend.ts
@@ -0,0 +1,29 @@
+// 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 {TrackState} from '../../common/state';
+import {Track} from '../../frontend/track';
+import {trackRegistry} from '../../frontend/track_registry';
+import {ChromeSliceTrack} from '../chrome_slices/frontend';
+
+import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from './common';
+
+export class ActualFramesSliceTrack extends ChromeSliceTrack {
+  static readonly kind = ACTUAL_FRAMES_SLICE_TRACK_KIND;
+  static create(trackState: TrackState): Track {
+    return new ActualFramesSliceTrack(trackState);
+  }
+}
+
+trackRegistry.register(ActualFramesSliceTrack);
diff --git a/ui/src/tracks/all_controller.ts b/ui/src/tracks/all_controller.ts
index 116c1b9..ca0b5c5 100644
--- a/ui/src/tracks/all_controller.ts
+++ b/ui/src/tracks/all_controller.ts
@@ -26,3 +26,5 @@
 import './thread_state/controller';
 import './async_slices/controller';
 import './debug_slices/controller';
+import './actual_frames/controller';
+import './expected_frames/controller';
diff --git a/ui/src/tracks/all_frontend.ts b/ui/src/tracks/all_frontend.ts
index 6311137..6af4d22 100644
--- a/ui/src/tracks/all_frontend.ts
+++ b/ui/src/tracks/all_frontend.ts
@@ -26,3 +26,5 @@
 import './thread_state/frontend';
 import './async_slices/frontend';
 import './debug_slices/frontend';
+import './actual_frames/frontend';
+import './expected_frames/frontend';
diff --git a/ui/src/tracks/chrome_slices/common.ts b/ui/src/tracks/chrome_slices/common.ts
index 5ed3cb7..41fc2f8 100644
--- a/ui/src/tracks/chrome_slices/common.ts
+++ b/ui/src/tracks/chrome_slices/common.ts
@@ -30,6 +30,7 @@
   ends: Float64Array;
   depths: Uint16Array;
   titles: Uint16Array;  // Index into strings.
+  colors?: Uint16Array;  // Index into strings.
   isInstant: Uint16Array;
   isIncomplete: Uint16Array;
 }
diff --git a/ui/src/tracks/chrome_slices/frontend.ts b/ui/src/tracks/chrome_slices/frontend.ts
index 83795ce..1fc3358 100644
--- a/ui/src/tracks/chrome_slices/frontend.ts
+++ b/ui/src/tracks/chrome_slices/frontend.ts
@@ -82,6 +82,7 @@
       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
         tEnd = tStart + INCOMPLETE_SLICE_TIME_S;
       }
@@ -101,8 +102,13 @@
       const saturation = isSelected ? 80 : 50;
       const highlighted = titleId === this.hoveredTitleId ||
           globals.frontendLocalState.highlightedSliceId === sliceId;
-      const color = `hsl(${hue}, ${saturation}%, ${highlighted ? 30 : 65}%)`;
 
+      let color: string;
+      if (colorOverride === undefined) {
+        color = `hsl(${hue}, ${saturation}%, ${highlighted ? 30 : 65}%)`;
+      } else {
+        color = colorOverride;
+      }
       ctx.fillStyle = color;
 
       // We draw instant events as upward facing chevrons starting at A:
diff --git a/ui/src/tracks/expected_frames/common.ts b/ui/src/tracks/expected_frames/common.ts
new file mode 100644
index 0000000..9245520
--- /dev/null
+++ b/ui/src/tracks/expected_frames/common.ts
@@ -0,0 +1,34 @@
+// 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 {TrackData} from '../../common/track_data';
+
+export const EXPECTED_FRAMES_SLICE_TRACK_KIND = 'ExpectedFramesSliceTrack';
+
+export interface Config {
+  maxDepth: number;
+  trackIds: number[];
+}
+
+export interface Data extends TrackData {
+  // Slices are stored in a columnar fashion. All fields have the same length.
+  strings: string[];
+  sliceIds: Float64Array;
+  starts: Float64Array;
+  ends: Float64Array;
+  depths: Uint16Array;
+  titles: Uint16Array;   // Index in |strings|.
+  colors?: Uint16Array;  // Index in |strings|.
+  isInstant: Uint16Array;
+  isIncomplete: Uint16Array;
+}
diff --git a/ui/src/tracks/expected_frames/controller.ts b/ui/src/tracks/expected_frames/controller.ts
new file mode 100644
index 0000000..97ab2d9
--- /dev/null
+++ b/ui/src/tracks/expected_frames/controller.ts
@@ -0,0 +1,125 @@
+// 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 {slowlyCountRows} from '../../common/query_iterator';
+import {fromNs, toNs} from '../../common/time';
+import {
+  TrackController,
+  trackControllerRegistry,
+} from '../../controller/track_controller';
+
+import {Config, Data, EXPECTED_FRAMES_SLICE_TRACK_KIND} from './common';
+
+class ExpectedFramesSliceTrackController extends TrackController<Config, Data> {
+  static readonly kind = EXPECTED_FRAMES_SLICE_TRACK_KIND;
+  private maxDurNs = 0;
+
+  async onBoundsChange(start: number, end: number, resolution: number):
+      Promise<Data> {
+    const startNs = toNs(start);
+    const endNs = toNs(end);
+
+    const pxSize = this.pxSize();
+
+    // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
+    // be an even number, so we can snap in the middle.
+    const bucketNs = Math.max(Math.round(resolution * 1e9 * pxSize / 2) * 2, 1);
+
+    if (this.maxDurNs === 0) {
+      const maxDurResult = await this.query(`
+        select max(dur)
+        from experimental_slice_layout
+        where filter_track_ids = '${this.config.trackIds.join(',')}'
+      `);
+      if (slowlyCountRows(maxDurResult) === 1) {
+        this.maxDurNs = maxDurResult.columns[0].longValues![0];
+      }
+    }
+
+    const rawResult = await this.query(`
+      SELECT
+        (ts + ${bucketNs / 2}) / ${bucketNs} * ${bucketNs} as tsq,
+        ts,
+        max(dur) as dur,
+        layout_depth,
+        name,
+        id,
+        dur = 0 as is_instant,
+        dur = -1 as is_incomplete
+      from experimental_slice_layout
+      where
+        filter_track_ids = '${this.config.trackIds.join(',')}' and
+        ts >= ${startNs - this.maxDurNs} and
+        ts <= ${endNs}
+      group by tsq, layout_depth
+      order by tsq, layout_depth
+    `);
+
+    const numRows = slowlyCountRows(rawResult);
+    const slices: Data = {
+      start,
+      end,
+      resolution,
+      length: numRows,
+      strings: [],
+      sliceIds: new Float64Array(numRows),
+      starts: new Float64Array(numRows),
+      ends: new Float64Array(numRows),
+      depths: new Uint16Array(numRows),
+      titles: new Uint16Array(numRows),
+      colors: new Uint16Array(numRows),
+      isInstant: new Uint16Array(numRows),
+      isIncomplete: new Uint16Array(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 greenIndex = internString('#4CAF50');
+
+    const cols = rawResult.columns;
+    for (let row = 0; row < numRows; row++) {
+      const startNsQ = +cols[0].longValues![row];
+      const startNs = +cols[1].longValues![row];
+      const durNs = +cols[2].longValues![row];
+      const endNs = startNs + durNs;
+
+      let endNsQ = Math.floor((endNs + bucketNs / 2 - 1) / bucketNs) * bucketNs;
+      endNsQ = Math.max(endNsQ, startNsQ + bucketNs);
+
+      if (startNsQ === endNsQ) {
+        throw new Error('Should never happen');
+      }
+
+      slices.starts[row] = fromNs(startNsQ);
+      slices.ends[row] = fromNs(endNsQ);
+      slices.depths[row] = +cols[3].longValues![row];
+      slices.titles[row] = internString(cols[4].stringValues![row]);
+      slices.sliceIds[row] = +cols[5].longValues![row];
+      slices.isInstant[row] = +cols[6].longValues![row];
+      slices.isIncomplete[row] = +cols[7].longValues![row];
+      slices.colors![row] = greenIndex;
+    }
+    return slices;
+  }
+}
+
+
+trackControllerRegistry.register(ExpectedFramesSliceTrackController);
diff --git a/ui/src/tracks/expected_frames/frontend.ts b/ui/src/tracks/expected_frames/frontend.ts
new file mode 100644
index 0000000..441f098
--- /dev/null
+++ b/ui/src/tracks/expected_frames/frontend.ts
@@ -0,0 +1,28 @@
+// 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 {TrackState} from '../../common/state';
+import {Track} from '../../frontend/track';
+import {trackRegistry} from '../../frontend/track_registry';
+import {ChromeSliceTrack} from '../chrome_slices/frontend';
+
+import {EXPECTED_FRAMES_SLICE_TRACK_KIND} from './common';
+
+export class ExpectedFramesSliceTrack extends ChromeSliceTrack {
+  static readonly kind = EXPECTED_FRAMES_SLICE_TRACK_KIND;
+  static create(trackState: TrackState): Track {
+    return new ExpectedFramesSliceTrack(trackState);
+  }
+}
+
+trackRegistry.register(ExpectedFramesSliceTrack);