git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitd39809d54de34155e8ee4901b11bab9e72616aeb
parent10b52b03dc
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:21
feat(playbar): show the cached and the stale frames of the simulations

A strip along the foot of the track: the accent over the frames every
simnet holds, amber over those held from the chain as it was before an
edit (the frames a mixed solve went on across, or a whole solve whose
simnet was edited since it ran).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       | 34 +++++++++++++++++++-
 src/app.rs      | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/geometry.rs | 96 +++++++++++++++++++++++++++++++++++++++++++++++++--------
 src/main.rs     | 77 +++++++++++++++++++++++++++++++++++++++++++++
 src/playbar.rs  | 49 +++++++++++++++++++++++++++++
 5 files changed, 337 insertions(+), 15 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e45798f..f06d20c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2354,7 +2354,8 @@ being frames of the solve as it was. So:
 
 - the frame in hand stands as it is, and the edit shows from the next
   frame forward, the solve going on from the state in hand;
-- such a solve is MIXED (`SimSolve::mixed`): its earlier frames are of
+- such a solve is MIXED (`SimSolve::edited_at`, the frame in hand at the
+  edit): its earlier frames are of
   the chain as it was. A scrub BACK from it has no checkpoint to resume
   from and does not keep the frame it leaves (which a scrub back from a
   clean solve does), so it re-solves from the seed with the edit in from
@@ -3339,6 +3340,37 @@ end and the start half a frame each, and with the cap would have stepped
 over the start. With Repeat off, landing on the far end stops there.
 `playback_plays_every_frame_however_late_the_tick` is the test.
 
+### The playbar shows what is cached, and what is stale (since 2026-10-06)
+
+A strip along the foot of the playbar's track says what the simulations
+hold: the accent where frames are CACHED, amber (the bypass tint) where
+they are STALE, nothing where they are not held. `SimCache::solved` reads
+each simnet's solve onto the timeline (`SolvedRange`): every frame from
+its start up to the furthest one in hand or kept as a checkpoint is
+cached, since the latest state and the checkpoints put any of them within
+an interval's steps; the frames up to an edit the solve went on across
+(`SimSolve::edited_at` — see "An edit is in from the next frame") are
+stale. `app::playbar_cache_runs` combines the simnets frame by frame —
+a frame is held when every simnet in the tree holds it (at or before its
+start it holds its seed), stale when any holds it stale — and runs it
+together; `State::sync_playbar_cache` hands the runs to
+`Playbar::cache` from the tick, worked out again only when the cache's
+`revision`, the geometry version or the frame range moves. A solve whose
+simnet has been edited since it ran is stale WHOLE: the simnet's
+`chain_hash` (its subtree, the half of the solve key that needs no
+evaluation) differs from the solve's, which is the case of a simnet
+nothing on screen reads, so the edit is in the tree and not yet solved.
+An upstream edit to an unsolved simnet's SEED is not seen that way (the
+seed needs an evaluation); the disk cache is not shown. A solve of a
+simnet deleted since counts for nothing.
+
+So: play to 60, cyan 1–60; edit the chain, amber 2–60 (the seed frame is
+the seed); play on to 100, cyan 61–100 after it; scrub back, and the
+mixed solve begins again from the seed — cyan up to where it stands.
+`the_playbar_cache_runs_say_what_is_cached_and_what_is_stale` is the
+rule, `the_playbar_shows_the_cached_and_the_stale_frames` a simnet
+played, edited and scrubbed. Checked in a shadow session.
+
 ### The playbar's right-click menu
 
 A right press on the playbar's plate (`over_playbar`) opens the sixth
diff --git a/src/app.rs b/src/app.rs
index 5ee0378..d6efd03 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1252,6 +1252,54 @@ pub fn strip_meta_children(root: &mut FsNode) {
 /// lowercased, as Houdini names its nodes (`sphere1`, `camera1`), since a
 /// path convention with exceptions is two conventions. Empty comes back as
 /// `node`, since a node with no name has no path at all.
+/// The playbar's cache strip, frame by frame over `start..=end` and run
+/// together: a frame is CACHED when every simnet in the tree has it in hand
+/// (at or before its start it shows its seed, which every simnet has), STALE
+/// when every one has it and one of them has it from the chain as it was —
+/// before an edit it went on across (`stale_to`), or before an edit not yet
+/// solved (`chains` differing from the solve's) — and in no run otherwise.
+/// A solve of a simnet not in `chains` (deleted since) does not count, and
+/// with none that does there is nothing to show.
+pub fn playbar_cache_runs(
+    solved: &[crate::geometry::SolvedRange],
+    chains: &std::collections::HashMap<String, u64>,
+    start: i32,
+    end: i32,
+) -> Vec<crate::playbar::CacheRun> {
+    use crate::playbar::{CacheRun, CacheState};
+    let sims: Vec<(&crate::geometry::SolvedRange, bool)> =
+        solved.iter().filter_map(|r| chains.get(&r.id).map(|c| (r, *c != r.chain))).collect();
+    let mut runs: Vec<CacheRun> = Vec::new();
+    if sims.is_empty() {
+        return runs;
+    }
+    for f in start..=end {
+        let mut held = Some(CacheState::Cached);
+        for (r, edited) in &sims {
+            let this = if f <= r.start {
+                Some(CacheState::Cached)
+            } else if f > r.reach {
+                None
+            } else if *edited || r.stale_to.is_some_and(|t| f <= t) {
+                Some(CacheState::Stale)
+            } else {
+                Some(CacheState::Cached)
+            };
+            held = match (held, this) {
+                (None, _) | (_, None) => None,
+                (Some(CacheState::Stale), _) | (_, Some(CacheState::Stale)) => Some(CacheState::Stale),
+                _ => Some(CacheState::Cached),
+            };
+        }
+        let Some(state) = held else { continue };
+        match runs.last_mut() {
+            Some(last) if last.state == state && last.to == f - 1 => last.to = f,
+            _ => runs.push(CacheRun { from: f, to: f, state }),
+        }
+    }
+    runs
+}
+
 pub fn sanitize_node_name(name: &str) -> String {
     let lowered = name.to_lowercase();
     let trimmed = lowered.trim();
@@ -3006,6 +3054,11 @@ pub struct State {
     /// Solved simulation states, kept across frames so playing forward costs one
     /// step per frame instead of re-solving from the start frame every redraw.
     pub sim_cache: crate::geometry::SimCache,
+    /// What the playbar's cache strip was last worked out from: the sim
+    /// cache's revision, the geometry version (an edit moves it, and may
+    /// make what is cached stale without solving anything) and the frame
+    /// range. See [`State::sync_playbar_cache`].
+    pub playbar_cache_key: Option<(u64, u64, i32, i32)>,
     /// The GPU image of the page the viewport shows. Owned here, so
     /// replacing a page frees the one it replaces.
     pub page_image: Option<u32>,
@@ -3844,6 +3897,44 @@ impl State {
         self.slots.playbar.inner().start_frame.round() as i32
     }
 
+    /// Hand the playbar what the simulations hold ([`playbar_cache_runs`]),
+    /// worked out again only when the cache, the geometry or the frame
+    /// range has moved. Only simnets still in the tree count, and a
+    /// solve whose simnet has been edited since it ran is stale whole —
+    /// the edit is in the tree but not solved, as for a simnet nothing on
+    /// screen reads. Returns whether the strip changed.
+    pub fn sync_playbar_cache(&mut self) -> bool {
+        let (start, end) = {
+            let pb = self.slots.playbar.inner();
+            (pb.start_frame.round() as i32, pb.end_frame.round() as i32)
+        };
+        let key = (self.sim_cache.revision(), self.rt_geometry_version, start, end);
+        if self.playbar_cache_key == Some(key) {
+            return false;
+        }
+        self.playbar_cache_key = Some(key);
+        let solved = self.sim_cache.solved();
+        let mut chains = std::collections::HashMap::new();
+        if !solved.is_empty() {
+            fn walk(n: &FsNode, solved: &[crate::geometry::SolvedRange], out: &mut std::collections::HashMap<String, u64>) {
+                if n.node_type.eq_ignore_ascii_case("simnet") && solved.iter().any(|r| r.id == n.id) {
+                    out.insert(n.id.clone(), crate::geometry::chain_hash(n));
+                }
+                for c in &n.children {
+                    walk(c, solved, out);
+                }
+            }
+            walk(&self.fs_root, &solved, &mut chains);
+        }
+        let runs = playbar_cache_runs(&solved, &chains, start, end);
+        let pb = self.slots.playbar.inner_mut();
+        if pb.cache == runs {
+            return false;
+        }
+        pb.cache = runs;
+        true
+    }
+
     pub fn get_col_geometries(&self) -> (f32, f32, f32, f32, f32, f32) {
         let left_visible = self.show_network && !self.circular_network_pane && !self.is_detached_network && !self.detached_circular_network;
         let center_visible = self.show_viewport || self.show_spreadsheet;
@@ -8559,6 +8650,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             network_menu_active: false,
             network_menu_actions: Vec::new(),
             sim_cache: crate::geometry::SimCache::default(),
+            playbar_cache_key: None,
             page_image: None,
             page_shown: None,
             page_composed: None,
@@ -12858,7 +12950,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             self.read_panel_offsets();
         }
 
-        tick_changed || panned || reclaimed || glow_animating || plate_animating || frame_moved || config_changed || light_moved
+        let cache_moved = self.sync_playbar_cache();
+
+        tick_changed || panned || reclaimed || glow_animating || plate_animating || frame_moved || config_changed || light_moved || cache_moved
     }
 
     /// Flush CPU-staged mesh updates to the renderer's persistent meshes.
diff --git a/src/geometry.rs b/src/geometry.rs
index 853c5ad..6728625 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1117,11 +1117,18 @@ struct SimSolve {
     /// from the nearest one behind it instead of the seed. See
     /// [`Checkpoint`].
     checkpoints: Checkpoints,
-    /// Whether the solve went on across an edit (a key change) from the
-    /// frame then in hand, so its earlier frames are of the chain as it
-    /// was: a scrub back does not keep the frame it leaves, and the solve
-    /// begins again from the seed.
-    mixed: bool,
+    /// Where the solve went on across an edit (a key change): the frame
+    /// then in hand, so it and the frames before it are of the chain as it
+    /// was — STALE, which the playbar shows. `None` for a solve of one key
+    /// throughout. A scrub back from a mixed solve does not keep the frame
+    /// it leaves, and the solve begins again from the seed.
+    edited_at: Option<i32>,
+    /// The timeline frame the solve shows its seed at (its Start Frame),
+    /// so what it holds can be put on the timeline.
+    start: i32,
+    /// [`chain_hash`] of the simnet as solved: what the app compares the
+    /// tree with to see that an edit has not been solved yet.
+    chain: u64,
 }
 
 /// A frame of a solve, kept in memory: its state and what its last substep
@@ -1217,6 +1224,9 @@ impl Checkpoints {
 #[derive(Default)]
 pub struct SimCache {
     entries: std::collections::HashMap<String, SimSolve>,
+    /// Moved by every solve put back, so a reader of [`SimCache::solved`]
+    /// can tell when to read again.
+    revision: u64,
     /// Runs of a chain since this cache was made — every substep of every
     /// simnet. What a solve COST, for the tests that a scrub resumes and
     /// does not re-solve.
@@ -1226,6 +1236,7 @@ pub struct SimCache {
 impl SimCache {
     pub fn clear(&mut self) {
         self.entries.clear();
+        self.revision += 1;
     }
 
     /// See [`SimCache::steps_run`].
@@ -1233,6 +1244,32 @@ impl SimCache {
         self.steps_run
     }
 
+    /// See [`SimCache::revision`].
+    pub fn revision(&self) -> u64 {
+        self.revision
+    }
+
+    /// What each simnet's solve holds, on the timeline: every frame from
+    /// its start up to the furthest one in hand or kept as a checkpoint is
+    /// cached — the latest state and the checkpoints between them put any
+    /// of those frames within an interval's steps — and the frames up to
+    /// an edit taken in mid-solve are stale.
+    pub fn solved(&self) -> Vec<SolvedRange> {
+        self.entries
+            .iter()
+            .map(|(id, e)| {
+                let reach = e.checkpoints.kept.last().map_or(e.frame, |c| c.frame.max(e.frame));
+                SolvedRange {
+                    id: id.clone(),
+                    start: e.start,
+                    reach: e.start + reach,
+                    stale_to: e.edited_at.map(|f| e.start + f),
+                    chain: e.chain,
+                }
+            })
+            .collect()
+    }
+
     /// The frames simnet `id` has checkpoints at, ascending — relative to
     /// its start frame, as the solve counts them.
     pub fn checkpoint_frames(&self, id: &str) -> Vec<i32> {
@@ -1240,6 +1277,22 @@ impl SimCache {
     }
 }
 
+/// One simnet's solve as [`SimCache::solved`] reads it, in timeline frames.
+#[derive(Debug, Clone, PartialEq)]
+pub struct SolvedRange {
+    /// The simnet's node id.
+    pub id: String,
+    /// The frame it shows its seed at.
+    pub start: i32,
+    /// The furthest frame cached.
+    pub reach: i32,
+    /// The last stale frame: the solve went on across an edit there, so
+    /// the frames after `start` up to this one are of the chain as it was.
+    pub stale_to: Option<i32>,
+    /// [`chain_hash`] of the simnet as solved.
+    pub chain: u64,
+}
+
 /// The simulation half of an evaluation: which frame the graph is being evaluated
 /// at, the solve cache, and the feedback stack that makes iteration possible.
 ///
@@ -1304,11 +1357,14 @@ impl<'a> EvalSim<'a> {
 /// node in the chain restarts the sim) and the seed geometry (so an upstream change
 /// does too).
 fn sim_solve_key(simnet: &FsNode, seed: &Detail) -> u64 {
+    sim_solve_key_of(chain_hash(simnet), seed)
+}
+
+/// [`sim_solve_key`] from a [`chain_hash`] already in hand.
+fn sim_solve_key_of(chain: u64, seed: &Detail) -> u64 {
     use std::hash::{Hash, Hasher};
     let mut h = std::collections::hash_map::DefaultHasher::new();
-    if let Ok(json) = serde_json::to_string(simnet) {
-        json.hash(&mut h);
-    }
+    chain.hash(&mut h);
     seed.num_points().hash(&mut h);
     for p in seed.positions() {
         for c in p {
@@ -1318,6 +1374,18 @@ fn sim_solve_key(simnet: &FsNode, seed: &Detail) -> u64 {
     h.finish()
 }
 
+/// Hash of a simnet's own subtree, the half of [`sim_solve_key`] that needs
+/// no evaluation: a solve whose chain differs from the tree's is of a chain
+/// that has been edited since, and is stale until it is solved again.
+pub fn chain_hash(simnet: &FsNode) -> u64 {
+    use std::hash::{Hash, Hasher};
+    let mut h = std::collections::hash_map::DefaultHasher::new();
+    if let Ok(json) = serde_json::to_string(simnet) {
+        json.hash(&mut h);
+    }
+    h.finish()
+}
+
 /// Evaluate with neither error reporting nor a persistent sim cache. Any simnet
 /// reached this way solves at frame 0 — that is, shows its seed — because there
 /// is no timeline in scope to say otherwise.
@@ -7211,7 +7279,8 @@ pub fn resolve_simnet_geometry_with_errors(
             .unwrap_or_default()
     };
 
-    let key = sim_solve_key(target, &seed);
+    let chain = chain_hash(target);
+    let key = sim_solve_key_of(chain, &seed);
     // The frame this sim shows its seed at. Empty means "follow the timeline",
     // which is what every sim did before this parameter existed; a number
     // decouples when a simulation starts from when the shot does, so two sims
@@ -7242,14 +7311,14 @@ pub fn resolve_simnet_geometry_with_errors(
             // after it is not resumed from by a scrub BACK: the frame it
             // leaves is not kept, so what was solved before the edit is
             // gone and the solve begins again from the seed.
-            if e.mixed && e.frame > due { None } else { Some(e) }
+            if e.edited_at.is_some() && e.frame > due { None } else { Some(e) }
         } else if e.frame > 0 && e.frame <= due {
-            Some(SimSolve { key, frame: e.frame, state: e.state, prev: e.prev, checkpoints: Checkpoints::default(), mixed: true })
+            Some(SimSolve { key, frame: e.frame, state: e.state, prev: e.prev, checkpoints: Checkpoints::default(), edited_at: Some(e.frame), start: e.start, chain: e.chain })
         } else {
             None
         }
     });
-    let mixed = prior.as_ref().is_some_and(|e| e.mixed);
+    let edited_at = prior.as_ref().and_then(|e| e.edited_at);
     let mut checkpoints = Checkpoints::default();
     let mut cached: Option<(Detail, Detail, i32)> = None;
     if let Some(prior) = prior {
@@ -7358,8 +7427,9 @@ pub fn resolve_simnet_geometry_with_errors(
     }
     sim.cache.entries.insert(
         target.id.clone(),
-        SimSolve { key, frame: due, state: state.clone(), prev: prev_frame, checkpoints, mixed },
+        SimSolve { key, frame: due, state: state.clone(), prev: prev_frame, checkpoints, edited_at, start: start_frame, chain },
     );
+    sim.cache.revision += 1;
     Some(state)
 }
 
diff --git a/src/main.rs b/src/main.rs
index 0f10b3e..006627b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18019,6 +18019,83 @@ mod tests {
         }
     }
 
+    /// The playbar's cache strip, as a rule: a frame is cached when every
+    /// simnet in the tree holds it, stale when one of them holds it from
+    /// the chain as it was — before an edit the solve went on across, or
+    /// anywhere in a solve whose simnet was edited since — and in no run
+    /// otherwise. A solve of a simnet no longer in the tree counts for
+    /// nothing.
+    #[test]
+    fn the_playbar_cache_runs_say_what_is_cached_and_what_is_stale() {
+        use crate::geometry::SolvedRange;
+        use crate::playbar::{CacheRun, CacheState::*};
+        let run = |from, to, state| CacheRun { from, to, state };
+        let solve = |id: &str, start, reach, stale_to| SolvedRange { id: id.into(), start, reach, stale_to, chain: 7 };
+        let chains = |ids: &[&str]| ids.iter().map(|i| (i.to_string(), 7u64)).collect::<std::collections::HashMap<_, _>>();
+        let runs = crate::app::playbar_cache_runs;
+
+        assert_eq!(runs(&[solve("a", 1, 20, None)], &chains(&["a"]), 1, 100), vec![run(1, 20, Cached)]);
+        assert_eq!(runs(&[solve("a", 1, 30, Some(20))], &chains(&["a"]), 1, 100), vec![run(1, 1, Cached), run(2, 20, Stale), run(21, 30, Cached)]);
+        // Edited since it was solved: stale up to where it reached; the seed is the seed.
+        let mut edited = chains(&["a"]);
+        edited.insert("a".into(), 8);
+        assert_eq!(runs(&[solve("a", 1, 30, None)], &edited, 1, 100), vec![run(1, 1, Cached), run(2, 30, Stale)]);
+        // Two simnets: a frame is held when both hold it, and before a
+        // simnet's start it holds its seed.
+        assert_eq!(
+            runs(&[solve("a", 1, 30, None), solve("b", 10, 20, Some(15))], &chains(&["a", "b"]), 1, 100),
+            vec![run(1, 10, Cached), run(11, 15, Stale), run(16, 20, Cached)]
+        );
+        // Deleted: not in the tree, nothing to show; and the range clips.
+        assert!(runs(&[solve("gone", 1, 30, None)], &chains(&["a"]), 1, 100).is_empty());
+        assert_eq!(runs(&[solve("a", 1, 30, None)], &chains(&["a"]), 5, 12), vec![run(5, 12, Cached)]);
+    }
+
+    /// The playbar shows what a simnet's solve holds as it is played, edited
+    /// and scrubbed: the frames played are cached; an edit at frame 20 makes
+    /// 2–20 stale (the solve goes on from the frame in hand); playing on
+    /// caches the frames after it; and a scrub back clears the stale frames,
+    /// the solve beginning again from the seed.
+    #[test]
+    fn the_playbar_shows_the_cached_and_the_stale_frames() {
+        use crate::playbar::{CacheRun, CacheState::*};
+        let run = |from, to, state| CacheRun { from, to, state };
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        let mut redraw = false;
+        state.apply_action(McpAction::AddNode { template_name: "Simnet".into(), name: Some("sim".into()), x: 6.0, y: 8.0 }, &mut redraw).unwrap();
+        let slot = state.current_dir().children.iter().position(|c| c.name == "sim").unwrap();
+        if !state.current_dir().children[slot].geometry_visible {
+            state.apply_action(McpAction::ToggleGeometry { slot }, &mut redraw).unwrap();
+        }
+        let go = |state: &mut State, frame: f32| {
+            state.slots.playbar.inner_mut().current_frame = frame;
+            state.tick_frame(1.0 / 60.0);
+        };
+        let strip = |state: &State| state.slots.playbar.inner().cache.clone();
+        for f in 1..=20 {
+            go(&mut state, f as f32);
+        }
+        assert_eq!(strip(&state), vec![run(1, 20, Cached)]);
+
+        state.apply_action(McpAction::SetParam { slot, name: "substeps".into(), value: "2".into() }, &mut redraw).unwrap();
+        state.tick_frame(1.0 / 60.0);
+        assert_eq!(strip(&state), vec![run(1, 1, Cached), run(2, 20, Stale)], "the frames solved before the edit are stale");
+
+        for f in 21..=25 {
+            go(&mut state, f as f32);
+        }
+        assert_eq!(strip(&state), vec![run(1, 1, Cached), run(2, 20, Stale), run(21, 25, Cached)]);
+
+        go(&mut state, 10.0);
+        assert_eq!(strip(&state), vec![run(1, 10, Cached)], "a scrub back solves again from the seed");
+        // A scrub back in a clean solve keeps the frame it left.
+        go(&mut state, 5.0);
+        assert_eq!(strip(&state), vec![run(1, 10, Cached)]);
+    }
+
     /// Repeat off: playback stops ON the last frame instead of wrapping,
     /// in either direction, and a play press on a timeline stopped at its
     /// far end restarts from the near one. The setting is a palette toggle
diff --git a/src/playbar.rs b/src/playbar.rs
index e2c5e8f..e50accf 100644
--- a/src/playbar.rs
+++ b/src/playbar.rs
@@ -39,9 +39,35 @@ pub struct Playbar {
     /// and bottom, and the lip is drawn over that band. Zero where the
     /// playbar is laid out anywhere else.
     pub frame: f32,
+    /// The frames the simulations hold, as runs: drawn as a strip along
+    /// the foot of the track, in the accent where they are cached and in
+    /// amber where they are stale. Set by the app from the sim cache
+    /// (`State::sync_playbar_cache`); empty with no simulation.
+    pub cache: Vec<CacheRun>,
     dragging: bool,
 }
 
+/// What the simulations hold of a run of frames.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CacheState {
+    /// Solved, of the chain as it is: playing or scrubbing here costs at
+    /// most a checkpoint interval's steps.
+    Cached,
+    /// Solved, but of the chain as it was before an edit.
+    Stale,
+}
+
+/// Frames `from..=to` and what is held of them.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct CacheRun {
+    pub from: i32,
+    pub to: i32,
+    pub state: CacheState,
+}
+
+/// The stale strip's colour: the amber a bypassed node wears.
+const STALE_COLOR: [f32; 4] = [1.0, 0.74, 0.18, 0.85];
+
 const PAD: f32 = 8.0;
 /// Space between two transport buttons.
 const BTN_GAP: f32 = 4.0;
@@ -67,6 +93,7 @@ impl Playbar {
             repeat: true,
             step_buttons: true,
             frame: 0.0,
+            cache: Vec::new(),
             dragging: false,
         })
     }
@@ -234,6 +261,28 @@ impl Paint for Playbar {
             &|x| cce_ui::widget::input::slider::band_profile(track.x, track.width, track.height, x, &[px], None),
         );
 
+        // What the simulations hold: a strip along the foot of the track,
+        // under the ticks. A frame k is the span k ± 0.5, so a run of one
+        // frame is as wide as a frame and the runs meet edge to edge.
+        if !self.cache.is_empty() {
+            let range = (self.end_frame - self.start_frame).max(1.0);
+            let at = |f: f32| track.x + ((f - self.start_frame) / range * track.width).clamp(0.0, track.width);
+            let accent = colors::highlight_primary_color();
+            let h = 3.0f32.min(track.height * 0.25);
+            for run in &self.cache {
+                let x0 = at(run.from as f32 - 0.5);
+                let x1 = at(run.to as f32 + 0.5);
+                if x1 - x0 <= 0.0 {
+                    continue;
+                }
+                let color = match run.state {
+                    CacheState::Cached => [accent[0], accent[1], accent[2], 0.85],
+                    CacheState::Stale => STALE_COLOR,
+                };
+                ctx.quad(Rect { x: x0, y: track.y + track.height - h, width: x1 - x0, height: h }, color);
+            }
+        }
+
         // Tick marks: frame steps on the 1-2-5 ladder, grown until minors sit
         // >=6px apart. Every 5th step is a major — taller, brighter, and
         // labeled with its frame number when the pane has room below the