graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(simnet): an explicit seed frame, and a disk cache that survives a restart
Start Frame decouples when a simulation begins from when the shot does, so two
sims in one scene can start at different times. Empty follows the timeline,
which is what every sim did before the parameter existed; EvalSim::steps_due is
gone, since the frame a sim is due at is now the simnet's question to answer.
Cache (off by default) parks the solved state on disk. It is opt-in because a
disk cache is a deliberate trade, not a silent background cost: writing a
hundred thousand points every frame is not free, and a solve that recomputes
quickly wants none of it.
Detail reads and writes itself in a compact binary form rather than through a
derived serializer. The one thing a cache is for is being cheaper than
recomputing, and a hundred thousand points of JSON text is not. Positions and
attribute arrays go out as raw little-endian floats; the derived topology does
not go out at all, because a file that stores what it can rebuild is a file
that can disagree with itself.
Three things the cache refuses, each because getting it wrong is worse than
having no cache:
- A state from a different chain. It is keyed by the same hash that invalidates
the in-memory cache, and a mismatched key reads as absent — a stale state
looks like an answer, which is worse than no answer.
- A state that has run PAST the frame being asked for. A step is not
invertible, so scrubbing back restarts from the seed, exactly as it already
did in memory.
- Anything malformed. Every length is checked against the bytes actually left,
so a truncated or corrupt file is an error rather than a huge allocation or a
panic; a test truncates a good blob at every seventh byte and corrupts bytes
through the middle. A cache lives in a directory anything can write to, and a
node id is not a filename — the path sanitizes, and a test tries to climb out
of the directory with it.
Writes go to a temporary file and are renamed, so a cache half-written when the
app dies is never read as a whole one.
Also clears two unused-mut warnings this session shipped in the Phase 1
deformer work: the warning check I had been running matched too narrow a
pattern to see them.
Co-Authored-By: Claude Opus 5 <[email protected]>
nodes/simnet.json | 10 +++
shapeshifter.md | 7 +-
src/detail.rs | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/geometry.rs | 233 +++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 484 insertions(+), 14 deletions(-)
diff --git a/nodes/simnet.json b/nodes/simnet.json
index 6388adb..f0b82e2 100644
--- a/nodes/simnet.json
+++ b/nodes/simnet.json
@@ -16,6 +16,16 @@
"min": 1.0,
"max": 64.0,
"step": 1.0
+ },
+ {
+ "name": "Start Frame",
+ "type": "text",
+ "default": ""
+ },
+ {
+ "name": "Cache",
+ "type": "choice:false,true",
+ "default": "false"
}
],
"children": [
diff --git a/shapeshifter.md b/shapeshifter.md
index c1fd807..d7e02dc 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -189,7 +189,12 @@ Touches: `geometry.rs`, `kernel_cpu.rs`, `nodes/*.json`.
> Composite — covers the same ground however finely the frame is cut, which is
> what makes substeps a stability control rather than a speed control.
>
-> Outstanding: an explicit seed frame, and a disk cache.
+> A simnet can declare its own Start Frame (empty follows the timeline), and
+> opt into a disk cache — the solved state parked under `$XDG_CACHE_HOME`, in a
+> compact binary form `Detail` reads and writes itself, keyed by the same hash
+> that invalidates the in-memory cache.
+>
+> Phase 2 is done.
`simnet` is already the Developer Solver — feedback stack, per-node cache keyed
on the subtree, restart on edit, one step per played frame. What it lacks is a
diff --git a/src/detail.rs b/src/detail.rs
index b16861a..a252931 100644
--- a/src/detail.rs
+++ b/src/detail.rs
@@ -665,6 +665,157 @@ impl AttribStore {
}
}
+const DETAIL_MAGIC: &[u8; 8] = b"CCEDTL01";
+
+fn put_u32(out: &mut Vec<u8>, v: u32) {
+ out.extend_from_slice(&v.to_le_bytes());
+}
+
+fn put_u64(out: &mut Vec<u8>, v: u64) {
+ out.extend_from_slice(&v.to_le_bytes());
+}
+
+fn put_str(out: &mut Vec<u8>, s: &str) {
+ put_u32(out, s.len() as u32);
+ out.extend_from_slice(s.as_bytes());
+}
+
+/// A bounds-checked cursor over a blob. Every read either yields the bytes it
+/// promised or fails; nothing here can index past the buffer.
+struct Reader<'a> {
+ b: &'a [u8],
+ at: usize,
+}
+
+impl<'a> Reader<'a> {
+ fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
+ let end = self.at.checked_add(n).ok_or("length overflow")?;
+ let slice = self.b.get(self.at..end).ok_or("unexpected end of blob")?;
+ self.at = end;
+ Ok(slice)
+ }
+ fn u32(&mut self) -> Result<u32, String> {
+ Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
+ }
+ fn u64(&mut self) -> Result<u64, String> {
+ Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap()))
+ }
+ fn f32(&mut self) -> Result<f32, String> {
+ Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
+ }
+ fn i32(&mut self) -> Result<i32, String> {
+ Ok(i32::from_le_bytes(self.take(4)?.try_into().unwrap()))
+ }
+ fn str(&mut self) -> Result<String, String> {
+ let n = self.u32()? as usize;
+ let bytes = self.take(n)?;
+ String::from_utf8(bytes.to_vec()).map_err(|_| "attribute name is not UTF-8".to_string())
+ }
+}
+
+impl AttribStore {
+ fn write_into(&self, out: &mut Vec<u8>) {
+ put_u32(out, self.len as u32);
+ let names = self.names();
+ put_u32(out, names.len() as u32);
+ for name in names {
+ let data = &self.attribs[name];
+ put_str(out, name);
+ out.push(match data.ty() {
+ AttribType::Float => 0,
+ AttribType::Float2 => 1,
+ AttribType::Float3 => 2,
+ AttribType::Float4 => 3,
+ AttribType::Int => 4,
+ });
+ out.push(match self.kind(name) {
+ AttribKind::Live => 0,
+ AttribKind::Derivative => 1,
+ });
+ match data {
+ AttribData::Float(v) => out.extend(v.iter().flat_map(|x| x.to_le_bytes())),
+ AttribData::Float2(v) => {
+ out.extend(v.iter().flatten().flat_map(|x| x.to_le_bytes()))
+ }
+ AttribData::Float3(v) => {
+ out.extend(v.iter().flatten().flat_map(|x| x.to_le_bytes()))
+ }
+ AttribData::Float4(v) => {
+ out.extend(v.iter().flatten().flat_map(|x| x.to_le_bytes()))
+ }
+ AttribData::Int(v) => out.extend(v.iter().flat_map(|x| x.to_le_bytes())),
+ }
+ }
+ let groups = self.group_names();
+ put_u32(out, groups.len() as u32);
+ for name in groups {
+ put_str(out, name);
+ out.extend(self.groups[name].iter().map(|&m| m as u8));
+ }
+ }
+
+ fn read_from(r: &mut Reader) -> Result<AttribStore, String> {
+ let len = r.u32()? as usize;
+ let mut store = AttribStore::with_len(len);
+ let n_attrs = r.u32()? as usize;
+ for _ in 0..n_attrs {
+ let name = r.str()?;
+ let ty = match r.take(1)?[0] {
+ 0 => AttribType::Float,
+ 1 => AttribType::Float2,
+ 2 => AttribType::Float3,
+ 3 => AttribType::Float4,
+ 4 => AttribType::Int,
+ other => return Err(format!("unknown attribute type {other}")),
+ };
+ let kind = match r.take(1)?[0] {
+ 0 => AttribKind::Live,
+ 1 => AttribKind::Derivative,
+ other => return Err(format!("unknown attribute kind {other}")),
+ };
+ let data = match ty {
+ AttribType::Int => {
+ let mut v = Vec::with_capacity(len.min(1 << 20));
+ for _ in 0..len {
+ v.push(r.i32()?);
+ }
+ AttribData::Int(v)
+ }
+ _ => {
+ let k = ty.components();
+ let mut flat = Vec::with_capacity((len * k).min(1 << 22));
+ for _ in 0..len * k {
+ flat.push(r.f32()?);
+ }
+ match ty {
+ AttribType::Float => AttribData::Float(flat),
+ AttribType::Float2 => {
+ AttribData::Float2(flat.chunks_exact(2).map(|c| [c[0], c[1]]).collect())
+ }
+ AttribType::Float3 => AttribData::Float3(
+ flat.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(),
+ ),
+ _ => AttribData::Float4(
+ flat.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect(),
+ ),
+ }
+ }
+ };
+ store.attribs.insert(name.clone(), data);
+ if kind != AttribKind::Live {
+ store.kinds.insert(name, kind);
+ }
+ }
+ let n_groups = r.u32()? as usize;
+ for _ in 0..n_groups {
+ let name = r.str()?;
+ let bits = r.take(len)?;
+ store.groups.insert(name, bits.iter().map(|&b| b != 0).collect());
+ }
+ Ok(store)
+ }
+}
+
fn append_data(lhs: &mut AttribData, rhs: &AttribData) {
match (lhs, rhs) {
(AttribData::Float(a), AttribData::Float(b)) => a.extend_from_slice(b),
@@ -1286,6 +1437,103 @@ impl Detail {
}
}
+ /// Serialize to a compact binary blob.
+ ///
+ /// Hand-rolled rather than derived, because the one thing a cache is for is
+ /// being cheaper than recomputing: a hundred thousand points of JSON text
+ /// is not. Positions and attribute arrays go out as raw little-endian
+ /// floats, which is also how they sit in memory.
+ ///
+ /// The derived topology is NOT written — it is rebuilt from the primitives
+ /// on read, and storing it would mean a file that can disagree with itself.
+ pub fn to_bytes(&self) -> Vec<u8> {
+ let mut out = Vec::new();
+ out.extend_from_slice(DETAIL_MAGIC);
+ put_u32(&mut out, self.pos.len() as u32);
+ put_u64(&mut out, self.next_id);
+ for p in &self.pos {
+ for c in p {
+ out.extend_from_slice(&c.to_le_bytes());
+ }
+ }
+ for id in &self.ids {
+ put_u64(&mut out, *id);
+ }
+ put_u32(&mut out, self.vert_point.len() as u32);
+ for v in &self.vert_point {
+ put_u32(&mut out, *v);
+ }
+ put_u32(&mut out, self.prim_start.len() as u32);
+ for v in &self.prim_start {
+ put_u32(&mut out, *v);
+ }
+ for store in [&self.points, &self.verts, &self.prims, &self.detail] {
+ store.write_into(&mut out);
+ }
+ out
+ }
+
+ /// Read back a blob written by [`Detail::to_bytes`].
+ ///
+ /// Every length is checked against what is actually left in the buffer, so
+ /// a truncated or corrupt cache file is an error rather than a huge
+ /// allocation or a panic. A cache lives in a directory anything can write
+ /// to, and must never be trusted the way a value from memory is.
+ pub fn from_bytes(bytes: &[u8]) -> Result<Detail, String> {
+ let mut r = Reader { b: bytes, at: 0 };
+ if r.take(DETAIL_MAGIC.len())? != DETAIL_MAGIC {
+ return Err("not a Detail blob".into());
+ }
+ let num_points = r.u32()? as usize;
+ let next_id = r.u64()?;
+ let mut pos = Vec::with_capacity(num_points.min(1 << 20));
+ for _ in 0..num_points {
+ pos.push([r.f32()?, r.f32()?, r.f32()?]);
+ }
+ let mut ids = Vec::with_capacity(pos.len());
+ for _ in 0..num_points {
+ ids.push(r.u64()?);
+ }
+ let nv = r.u32()? as usize;
+ let mut vert_point = Vec::with_capacity(nv.min(1 << 20));
+ for _ in 0..nv {
+ vert_point.push(r.u32()?);
+ }
+ let ns = r.u32()? as usize;
+ let mut prim_start = Vec::with_capacity(ns.min(1 << 20));
+ for _ in 0..ns {
+ prim_start.push(r.u32()?);
+ }
+ if prim_start.is_empty() {
+ return Err("primitive offsets are missing their terminator".into());
+ }
+ let points = AttribStore::read_from(&mut r)?;
+ let verts = AttribStore::read_from(&mut r)?;
+ let prims = AttribStore::read_from(&mut r)?;
+ let detail = AttribStore::read_from(&mut r)?;
+
+ // Cross-checks, because every reader below indexes on these being
+ // consistent and a corrupt file must not reach that code.
+ if points.len() != num_points || verts.len() != nv || prims.len() != ns - 1 {
+ return Err("element counts disagree with their attribute stores".into());
+ }
+ if vert_point.iter().any(|&p| p as usize >= num_points.max(1)) && num_points > 0 {
+ return Err("a vertex references a point that is not there".into());
+ }
+ Ok(Detail {
+ pos,
+ ids,
+ next_id,
+ points,
+ vert_point,
+ verts,
+ prim_start,
+ prims,
+ detail,
+ topo: OnceLock::new(),
+ })
+ }
+
/// Weld a triangle soup into points and triangles: coincident positions
/// become one point, every three positions become one primitive.
///
diff --git a/src/geometry.rs b/src/geometry.rs
index 870886e..b074591 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -640,12 +640,6 @@ impl<'a> EvalSim<'a> {
Self { frame, start_frame, cache, feedback: Vec::new() }
}
- /// Steps the sim owes at the frame being evaluated. Scrubbing before the
- /// start frame is not negative time — it is simply the seed.
- fn steps_due(&self) -> i32 {
- (self.frame - self.start_frame).max(0)
- }
-
/// The state an `input` node should yield, if its parent simnet is mid-solve.
fn feedback_for(&self, simnet_id: &str) -> Option<&Detail> {
self.feedback
@@ -3403,7 +3397,7 @@ fn run_deformer_flat(
param_data.push(0.0);
}
- let mut make = |data: &[cl_float]| -> Result<ClBuffer<cl_float>, String> {
+ let make = |data: &[cl_float]| -> Result<ClBuffer<cl_float>, String> {
let mut buf = unsafe {
ClBuffer::<cl_float>::create(context, CL_MEM_READ_WRITE, data.len().max(1), std::ptr::null_mut())
.map_err(|e| format!("Failed to create buffer: {:?}", e))?
@@ -3418,7 +3412,7 @@ fn run_deformer_flat(
let mut pos_buf = make(pos)?;
let mut col_buf = make(col)?;
- let mut param_buf = make(¶m_data)?;
+ let param_buf = make(¶m_data)?;
let mut attr_bufs: Vec<ClBuffer<cl_float>> = Vec::with_capacity(attrs.len());
for (_, data) in attrs.iter() {
attr_bufs.push(make(data)?);
@@ -4983,14 +4977,27 @@ pub fn resolve_simnet_geometry_with_errors(
};
let key = sim_solve_key(target, &seed);
- let due = sim.steps_due();
+ // 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
+ // in one scene can begin at different times.
+ let start_frame = node_param_f32(target, "Start Frame", sim.start_frame as f32).round() as i32;
+ let due = (sim.frame - start_frame).max(0);
// Resume from the cached solve when it is still valid and has not run PAST
// the frame asked for; scrubbing backwards has to restart from the seed,
- // because a step is not invertible.
- let (mut state, mut done) = match sim.cache.entries.get(&target.id) {
- Some(prev) if prev.key == key && prev.frame <= due => (prev.state.clone(), prev.frame),
- _ => (seed, 0),
+ // because a step is not invertible. Memory first, then disk.
+ let cached = sim.cache.entries.get(&target.id).and_then(|prev| {
+ (prev.key == key && prev.frame <= due).then(|| (prev.state.clone(), prev.frame))
+ });
+ let caching = node_param_str(target, "Cache", "false") == "true";
+ let (mut state, mut done) = match cached {
+ Some(hit) => hit,
+ None if caching => match read_sim_cache(&target.id, key, due) {
+ Some(hit) => hit,
+ None => (seed, 0),
+ },
+ None => (seed, 0),
};
// Substeps run the chain more than once per frame. A step's size is what
@@ -5048,9 +5055,77 @@ pub fn resolve_simnet_geometry_with_errors(
target.id.clone(),
SimSolve { key, frame: due, state: state.clone() },
);
+ if caching && due > 0 {
+ write_sim_cache(&target.id, key, due, &state);
+ }
Some(state)
}
+/// Where a simnet's solved state is parked between runs.
+///
+/// Under the cache directory, not the project: it is derived data that can be
+/// recomputed, and a project directory that silently grew hundreds of
+/// megabytes of solver state would be a nasty surprise to copy or back up.
+fn sim_cache_path(node_id: &str) -> Option<std::path::PathBuf> {
+ let base = std::env::var_os("XDG_CACHE_HOME")
+ .map(std::path::PathBuf::from)
+ .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".cache")))?;
+ // The id is a node id, not a filename, so anything that could climb out of
+ // the directory is replaced rather than trusted.
+ let safe: String = node_id
+ .chars()
+ .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
+ .collect();
+ Some(base.join("cce/cce-designer/sim").join(format!("{safe}.simcache")))
+}
+
+/// The header in front of a cached state: which solve it belongs to and which
+/// frame it stopped at. Both are checked before the geometry is trusted — a
+/// cache from a different chain is worse than no cache, because it looks like
+/// an answer.
+fn write_sim_cache(node_id: &str, key: u64, frame: i32, state: &Detail) {
+ let Some(path) = sim_cache_path(node_id) else { return };
+ write_sim_cache_at(&path, key, frame, state);
+}
+
+/// [`write_sim_cache`] against a given path, so the format can be exercised
+/// without a process-wide environment variable.
+fn write_sim_cache_at(path: &std::path::Path, key: u64, frame: i32, state: &Detail) {
+ let Some(dir) = path.parent() else { return };
+ if std::fs::create_dir_all(dir).is_err() {
+ return;
+ }
+ let mut blob = Vec::new();
+ blob.extend_from_slice(&key.to_le_bytes());
+ blob.extend_from_slice(&frame.to_le_bytes());
+ blob.extend_from_slice(&state.to_bytes());
+ // Written beside the target and renamed, so a cache half-written when the
+ // app dies is never read as a whole one.
+ let tmp = path.with_extension("simcache.tmp");
+ if std::fs::write(&tmp, &blob).is_ok() {
+ let _ = std::fs::rename(&tmp, &path);
+ }
+}
+
+/// A cached state for this solve, if one is on disk and has not run past the
+/// frame being asked for. Any failure — missing, truncated, stale, corrupt —
+/// reads as "no cache" and the sim solves from its seed.
+fn read_sim_cache(node_id: &str, key: u64, due: i32) -> Option<(Detail, i32)> {
+ read_sim_cache_at(&sim_cache_path(node_id)?, key, due)
+}
+
+fn read_sim_cache_at(path: &std::path::Path, key: u64, due: i32) -> Option<(Detail, i32)> {
+ let blob = std::fs::read(path).ok()?;
+ if blob.len() < 12 || u64::from_le_bytes(blob[0..8].try_into().ok()?) != key {
+ return None;
+ }
+ let frame = i32::from_le_bytes(blob[8..12].try_into().ok()?);
+ if frame < 0 || frame > due {
+ return None;
+ }
+ Detail::from_bytes(&blob[12..]).ok().map(|d| (d, frame))
+}
+
/// Does this graph contain a simnet anywhere? The frame-change invalidation asks
/// before rebuilding the scene, since for a graph without one the timeline
/// changes nothing.
@@ -6247,6 +6322,138 @@ mod simnet_tests {
root
}
+ #[test]
+ fn test_a_simnet_can_start_later_than_the_timeline() {
+ let with_start = |start: &str| {
+ let mut root = substep_graph("1");
+ let sim = root.children.iter_mut().find(|c| c.node_type == "simnet").unwrap();
+ sim.params.push(param("Start Frame", start));
+ root
+ };
+ let acc = |root: &FsNode, frame: i32| -> f32 {
+ solve_at(root, frame).points().value("acc", 0).unwrap().as_f32()
+ };
+
+ // Empty follows the timeline, which is what every sim did before this
+ // parameter existed.
+ assert_eq!(acc(&with_start(""), 4), 3.0);
+
+ // A number decouples when the simulation starts from when the shot
+ // does, so two sims in one scene can begin at different times.
+ let late = with_start("5");
+ assert_eq!(acc(&late, 5), 0.0, "its own start frame is its seed");
+ assert_eq!(acc(&late, 8), 3.0);
+ // Before it starts is not negative time, exactly as before the
+ // timeline's start was not.
+ assert_eq!(acc(&late, 2), 0.0);
+ }
+
+ #[test]
+ fn test_a_detail_round_trips_through_its_binary_form() {
+ let mut d = sphere_detail(Vec3::new(0.1, 0.2, 0.3), 0.7, 4, 6);
+ d.points_mut().create("mass", AttribValue::Float(0.0));
+ for p in 0..d.num_points() {
+ d.points_mut().set_value("mass", p, AttribValue::Float(p as f32 * 0.25)).unwrap();
+ }
+ d.points_mut().create_kind("scratch", AttribValue::Int(3), crate::detail::AttribKind::Derivative);
+ d.points_mut().create_group("pinned");
+ d.points_mut().add_to_group("pinned", 2);
+ d.prims_mut().create("area", AttribValue::Float(1.5));
+ d.detail_mut().create("dt", AttribValue::Float(0.25));
+ d.verts_mut().create("uv", AttribValue::Float2([0.5, 0.25]));
+
+ let blob = d.to_bytes();
+ let back = Detail::from_bytes(&blob).expect("round trip");
+
+ assert_eq!(back.num_points(), d.num_points());
+ assert_eq!(back.num_prims(), d.num_prims());
+ assert_eq!(back.num_verts(), d.num_verts());
+ assert_eq!(back.positions(), d.positions());
+ // Identity is the whole reason a solver state is worth storing: a
+ // resumed sim that renumbered its points would be a different sim.
+ assert_eq!(back.ids(), d.ids());
+ assert_eq!(back.points().value("mass", 5), d.points().value("mass", 5));
+ assert_eq!(back.points().value("scratch", 0), Some(AttribValue::Int(3)));
+ assert_eq!(back.points().kind("scratch"), crate::detail::AttribKind::Derivative);
+ assert_eq!(back.points().kind("mass"), crate::detail::AttribKind::Live);
+ assert_eq!(back.points().group_members("pinned"), vec![2]);
+ assert_eq!(back.prims().value("area", 0), Some(AttribValue::Float(1.5)));
+ assert_eq!(back.detail().value("dt", 0), Some(AttribValue::Float(0.25)));
+ assert_eq!(back.verts().value("uv", 0), Some(AttribValue::Float2([0.5, 0.25])));
+ // Topology is rebuilt rather than stored, so it cannot disagree with
+ // the primitives it came from.
+ assert_eq!(back.edges(), d.edges());
+
+ // A point added after a resume must not reuse an identity.
+ let mut back = back;
+ let fresh = back.add_point(Vec3::ZERO);
+ assert!(!d.ids().contains(&back.id(fresh as usize).unwrap()));
+ }
+
+ #[test]
+ fn test_a_corrupt_cache_blob_is_an_error_not_a_panic() {
+ let good = sphere_detail(Vec3::ZERO, 0.5, 4, 6).to_bytes();
+ assert!(Detail::from_bytes(b"").is_err(), "empty");
+ assert!(Detail::from_bytes(b"not a detail at all").is_err(), "wrong magic");
+ // Truncated at every length: a cache lives in a directory anything can
+ // write to, and half a file must never reach the code that indexes on
+ // its counts.
+ for cut in (0..good.len()).step_by(7) {
+ let _ = Detail::from_bytes(&good[..cut]);
+ }
+ let mut wrong_type = good.clone();
+ // Corrupt a byte in the middle and it either errors or reads as
+ // something harmless; what it must not do is panic.
+ for i in (8..good.len()).step_by(101) {
+ wrong_type[i] = 0xff;
+ let _ = Detail::from_bytes(&wrong_type);
+ wrong_type[i] = good[i];
+ }
+ }
+
+ #[test]
+ fn test_the_disk_cache_resumes_only_the_solve_it_belongs_to() {
+ let dir = std::env::temp_dir().join(format!("cce-simcache-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ let path = dir.join("sim.simcache");
+ let mut state = sphere_detail(Vec3::ZERO, 0.5, 4, 6);
+ state.points_mut().create("acc", AttribValue::Float(9.0));
+
+ write_sim_cache_at(&path, 0xABCD, 12, &state);
+ assert!(path.exists(), "the cache was written");
+
+ // The right solve, at or before the frame being asked for.
+ let (got, frame) = read_sim_cache_at(&path, 0xABCD, 20).expect("a matching cache resumes");
+ assert_eq!(frame, 12);
+ assert_eq!(got.points().value("acc", 0), Some(AttribValue::Float(9.0)));
+ assert_eq!(got.ids(), state.ids());
+
+ // A cache from a DIFFERENT chain is worse than no cache, because it
+ // looks like an answer.
+ assert!(read_sim_cache_at(&path, 0x1234, 20).is_none(), "a stale key must not resume");
+ // Having run past the frame asked for, it cannot help: a step is not
+ // invertible, so scrubbing back restarts from the seed.
+ assert!(read_sim_cache_at(&path, 0xABCD, 5).is_none(), "a future state must not resume");
+ // A file that is not there, or is rubbish, reads as "no cache".
+ assert!(read_sim_cache_at(&dir.join("absent"), 0xABCD, 20).is_none());
+ std::fs::write(&path, b"rubbish").unwrap();
+ assert!(read_sim_cache_at(&path, 0xABCD, 20).is_none());
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_a_cache_filename_cannot_climb_out_of_its_directory() {
+ // The id is a node id, not a filename, and a project file is data.
+ let path = sim_cache_path("../../../etc/passwd").expect("a cache path");
+ assert!(!path.to_string_lossy().contains(".."), "{path:?}");
+ assert!(path.to_string_lossy().ends_with("_________etc_passwd.simcache"), "{path:?}");
+ assert!(
+ path.to_string_lossy().contains("cce/cce-designer/sim"),
+ "derived state belongs under the cache directory: {path:?}"
+ );
+ }
+
#[test]
fn test_substeps_run_the_chain_more_than_once_per_frame() {
let acc = |root: &FsNode, frame: i32| -> f32 {