perf(runtime, performance): preserve path geometry on rigid transform (#14315) 4c824e6f05
* perf(runtime): keep a shape's local path across a rigid move

A shape's local path is each path's geometry mapped by inverseWorld *
pathTransform -- the path's transform relative to the shape. That survives any
rigid move: an ancestor translating, rotating or scaling moves the shape and
its paths together, so the local path the composer rebuilds is the one it
already holds. It rebuilt anyway, because a world transform change routes
through Path::onDirty -> Shape::pathChanged() as plain Path dirt.

The copy was never the expensive part. Rebuilding rewinds the ShapePaintPath,
which rewinds the RenderPath, which bumps a monotonic mutation id -- so the
cached inner-fan triangulation is dropped and rebuilt for geometry that did not
change. Worse, building one is charged against TriangulationController's frame
budget, and once that is spent every remaining path in the frame falls back to
midpoint fans; cache hits cost nothing.

So snapshot what the local path is built from -- each path's geometry version
and its transform relative to the shape -- and skip the rebuild when none of it
moved. Measured on a rigidly moving artboard: car_widgets 20.8 -> 15.1 us/frame
in the update pass (-27%, the same for rotation as for translation), zombie
skins 104 -> 90 us/frame, and re-triangulating those files' paths costs 41 and
140 us/frame respectively, which the retained cache now avoids entirely.

The comparison cannot be exact. Recomposing inverseWorld * pathTransform after
a move is an A^-1 * A round trip, so it lands a rounding step from the matrix
the buffer was built with -- under translation only in the translation, under
rotation in the linear part too. The tolerance is derived from the terms that
produce the value, and is compared against the last built transform rather than
the last frame, so the error is bounded rather than accumulating: at most a few
ULPs of the coordinates involved, measured at 3.7e-04 artboard units across
twelve real files. A sweep from 1 to 1024 ULPs moves the skip rate by under 3
points, so the constant is not load bearing.

Skinned paths rebuild their raw geometry every frame (the skin bakes world
space bone transforms into the vertices), so they keep rebuilding; making skins
shape relative would extend this to them.

Note this changes the silver command streams, which record the rewind and
addRawPath calls that no longer happen. 69 of them need rebaselining; nothing
else in the suite moves.

* test(runtime): render a tarnished silver next to its baseline

A silver fails on any byte difference in the recorded command stream, which is
stricter than "the frame changed": dropping a redundant rewind and addRawPath
pair moves those bytes without moving a pixel. Until now the only thing a
failure told you was the size of the stream, so a rebaseline came down to
trust.

Everything needed to do better was already in the binary. The SRIV stream
records at the Factory and Renderer level and carries its image bytes and mesh
buffers inline, so replaySerializedCommands can replay it with no .riv file or
fonts; unit_tests already links rive_cg_renderer and tools_common, so it can
replay into a CoreGraphics bitmap and write PNGs. Both sides go through that
same CPU renderer, so the comparison is exact even where the rasterizer is
approximate -- there is no golden to disagree with.

Hashes the frames first and only keeps pixels for the frames that differ (a
2370x1680 stream is 16MB a frame), then writes the baseline, the tarnished
render, a map of the differing pixels and a padded close-up of the region they
fall in, because the evidence is usually a handful of pixels on an
anti-aliased edge.

Hidden behind a dot tag, and deliberately run against the built binary rather
than through test.sh, which wipes silvers/tarnished on startup:

    ./out/release/unit_tests "[.silverdiff]"

* rebased tests

* fix(runtime): address review on the rigid transform path skip

- Do not run the snapshot for a shape with no local path. A clip source or a
  world stroke was paying for an inverse, a scratch vector and a per path
  compare whose result the world branch never reads. A shape that gains a
  local flag later still arrives here with no snapshot, which reads as
  changed, so it still builds.

- Release a CGRenderer before the context it draws into. ~CGRenderer restores
  a graphics state on its context, so releasing the context first left that
  restore reading through freed memory -- on the frame size change and again
  on the size probe.

- Fail when a tarnished silver has no readable baseline. It only counted the
  file as unreadable and carried on, so a run could report success having
  validated nothing, which is the one thing a rebaseline check must not do.

- clang-format (19.1.7, matching CI).

Drops silvers/super_ultra_mega_rig_bound_v002.sriv, which was picked up by the
rebaseline rather than authored: it is the only added silver in the set, and
its .riv lives outside the repo.

Co-authored-by: hernan <hernan@rive.app>
diff --git a/.rive_head b/.rive_head
index 53ab4fe..3efaad0 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-10821b03994f98cf1fc3d67f663db0d41431998f
+4c824e6f05d23dfc0c9d22d7496c33a782ccc366
diff --git a/include/rive/shapes/path.hpp b/include/rive/shapes/path.hpp
index 1741819..92fd68e 100644
--- a/include/rive/shapes/path.hpp
+++ b/include/rive/shapes/path.hpp
@@ -45,6 +45,9 @@
     void shapePathChanged();
     PathFlags m_pathFlags = PathFlags::none;
     RawPath m_rawPath;
+    // Bumped whenever m_rawPath is rebuilt, so a consumer can tell "this is the
+    // same geometry I saw last time" apart from "it was rebuilt".
+    uint32_t m_geometryVersion = 0;
     RenderPathDeformer* deformer() const;
     void isHoleChanged() override;
 
@@ -66,6 +69,7 @@
     virtual const Mat2D& pathTransform() const;
     bool collapse(bool value) override;
     const RawPath& rawPath() const { return m_rawPath; }
+    uint32_t geometryVersion() const { return m_geometryVersion; }
     // True while m_rawPath has yet to be rebuilt for pending changes: the Path
     // dirt is still queued, or the build was deferred. Layout runs before
     // Path::update in the update pass, so a measure can land here first and
diff --git a/include/rive/shapes/path_composer.hpp b/include/rive/shapes/path_composer.hpp
index e8605ff..62e6bae 100644
--- a/include/rive/shapes/path_composer.hpp
+++ b/include/rive/shapes/path_composer.hpp
@@ -4,6 +4,9 @@
 #include "rive/shapes/shape_paint_path.hpp"
 #include "rive/refcnt.hpp"
 #include "rive/math/raw_path.hpp"
+#include "rive/shapes/path_flags.hpp"
+#include <cmath>
+#include <vector>
 
 namespace rive
 {
@@ -26,7 +29,48 @@
 
     void pathCollapseChanged();
 
+    // The inputs a local path is built from, per path, as of the last build.
+    struct LocalPathInput
+    {
+        Mat2D transform;
+        uint32_t geometryVersion;
+        bool skipped;
+        float linearTolerance;
+        float translationTolerance;
+
+        // The composed transform is inverseWorld * pathTransform, so it
+        // carries the rounding of an A^-1 * A round trip: bit equality never
+        // holds for a shape that has moved, even though the exact result is
+        // unchanged. The two halves drift at different scales, so they get
+        // separate tolerances, and both are compared against the transform the
+        // buffer was actually built from -- which is what keeps the error
+        // bounded by the tolerance instead of accumulating frame over frame.
+        bool matches(const LocalPathInput& o) const
+        {
+            if (geometryVersion != o.geometryVersion || skipped != o.skipped)
+            {
+                return false;
+            }
+            for (int i = 0; i < 4; i++)
+            {
+                if (std::abs(transform[i] - o.transform[i]) > o.linearTolerance)
+                {
+                    return false;
+                }
+            }
+            return std::abs(transform[4] - o.transform[4]) <=
+                       o.translationTolerance &&
+                   std::abs(transform[5] - o.transform[5]) <=
+                       o.translationTolerance;
+        }
+    };
+    bool localInputsChanged();
+
 private:
+    std::vector<LocalPathInput> m_localInputs;
+    std::vector<LocalPathInput> m_scratchInputs;
+    PathFlags m_builtLocalFlags = PathFlags::none;
+    bool m_hasLocalInputs = false;
     Shape* m_shape;
     ShapePaintPath m_localPath;
     ShapePaintPath m_worldPath;
diff --git a/src/shapes/path.cpp b/src/shapes/path.cpp
index 4359e1f..6312be5 100644
--- a/src/shapes/path.cpp
+++ b/src/shapes/path.cpp
@@ -481,6 +481,7 @@
         // tester).
         m_rawPath.rewind();
         buildPath(m_rawPath);
+        m_geometryVersion++;
     }
     // if (hasDirt(value, ComponentDirt::WorldTransform) && m_Shape != nullptr)
     // {
diff --git a/src/shapes/path_composer.cpp b/src/shapes/path_composer.cpp
index 344467b..a380783 100644
--- a/src/shapes/path_composer.cpp
+++ b/src/shapes/path_composer.cpp
@@ -2,6 +2,8 @@
 #include "rive/artboard.hpp"
 #include "rive/renderer.hpp"
 #include "rive/shapes/path.hpp"
+#include <algorithm>
+#include <limits>
 #include "rive/shapes/shape.hpp"
 #include "rive/factory.hpp"
 #include "rive/shapes/points_path.hpp"
@@ -38,6 +40,81 @@
     }
 }
 
+// A local path is each path's geometry mapped by inverseShapeWorld *
+// pathTransform -- the path's transform relative to the shape. A rigid move of
+// the shape (or of any ancestor) leaves every one of those unchanged, so the
+// local path it would rebuild is identical to the one it already holds.
+// Rebuilding it anyway costs the copy and, worse, bumps the RenderPath's
+// mutation id, which throws away its cached triangulation. Snapshot the inputs
+// and compare.
+bool PathComposer::localInputsChanged()
+{
+    auto& paths = m_shape->paths();
+    const Mat2D& world = m_shape->worldTransform();
+    const Mat2D inverseWorld = world.invertOrIdentity();
+    // The composed transform is inverseWorld * pathTransform, and the error
+    // in it is dominated by the inverse: its linear part scales the path's
+    // coordinates, and inverting a shrunk-down world amplifies everything by
+    // 1/det. So bound the tolerance by the terms that actually produce the
+    // value rather than by the world transform alone.
+    // 16 ULPs of the terms above. A sweep over 1..1024 ULPs on twelve real
+    // files moved the skip rate by less than 3 points, so this is nowhere near
+    // a knife edge.
+    constexpr float kULPs = 16.0f * std::numeric_limits<float>::epsilon();
+    const float inverseLinear = std::max({std::abs(inverseWorld[0]),
+                                          std::abs(inverseWorld[1]),
+                                          std::abs(inverseWorld[2]),
+                                          std::abs(inverseWorld[3])});
+    const float inverseTranslation =
+        std::max(std::abs(inverseWorld[4]), std::abs(inverseWorld[5]));
+
+    m_scratchInputs.clear();
+    m_scratchInputs.reserve(paths.size());
+    for (auto path : paths)
+    {
+        const Mat2D& pathWorld = path->pathTransform();
+        const float pathLinear = std::max({std::abs(pathWorld[0]),
+                                           std::abs(pathWorld[1]),
+                                           std::abs(pathWorld[2]),
+                                           std::abs(pathWorld[3])});
+        const float pathTranslation =
+            std::max(std::abs(pathWorld[4]), std::abs(pathWorld[5]));
+        const float linearTolerance =
+            kULPs * std::max(1.0f, inverseLinear * pathLinear);
+        const float translationTolerance =
+            kULPs *
+            std::max(1.0f,
+                     inverseLinear * pathTranslation + inverseTranslation);
+        m_scratchInputs.push_back({inverseWorld * pathWorld,
+                                   path->geometryVersion(),
+                                   path->isHidden() || path->isCollapsed(),
+                                   linearTolerance,
+                                   translationTolerance});
+    }
+
+    // A shape can gain a local path flag after we have already snapshotted
+    // (a fill added, a clip registered), so a block that has never been built
+    // against this snapshot has to rebuild no matter what the inputs say.
+    const PathFlags localFlags =
+        m_shape->pathFlags() & (PathFlags::local | PathFlags::localClockwise);
+    bool changed = !m_hasLocalInputs || m_localInputs.size() != paths.size() ||
+                   (localFlags & ~m_builtLocalFlags) != PathFlags::none;
+    for (size_t i = 0; !changed && i < paths.size(); i++)
+    {
+        changed = !m_localInputs[i].matches(m_scratchInputs[i]);
+    }
+    if (changed)
+    {
+        // Only adopt the new inputs when we are about to rebuild from them.
+        // Holding the ones the buffer was actually built from is what keeps the
+        // tolerance from accumulating frame over frame.
+        m_localInputs = m_scratchInputs;
+        m_hasLocalInputs = true;
+        m_builtLocalFlags = localFlags;
+    }
+    return changed;
+}
+
 void PathComposer::update(ComponentDirt value)
 {
     m_shapeNotified = false;
@@ -50,7 +127,14 @@
         }
         m_deferredPathDirt = false;
 
-        if (m_shape->isFlagged(PathFlags::local))
+        // Only the local blocks consult the snapshot, so a shape with just a
+        // world path (a clip source, a world stroke) should not pay for the
+        // inverse and the per-path compare. A shape that gains a local flag
+        // later still lands here with no snapshot, which reads as changed.
+        const bool rebuildLocal =
+            m_shape->isFlagged(PathFlags::local | PathFlags::localClockwise) &&
+            localInputsChanged();
+        if (m_shape->isFlagged(PathFlags::local) && rebuildLocal)
         {
             m_localPath.rewind();
             auto world = m_shape->worldTransform();
@@ -66,7 +150,7 @@
                 }
             }
         }
-        if (m_shape->isFlagged(PathFlags::localClockwise))
+        if (m_shape->isFlagged(PathFlags::localClockwise) && rebuildLocal)
         {
             m_localClockwisePath.rewind();
             auto world = m_shape->worldTransform();
diff --git a/tests/unit_tests/runtime/path_composer_rebuild_test.cpp b/tests/unit_tests/runtime/path_composer_rebuild_test.cpp
new file mode 100644
index 0000000..2c4b47b
--- /dev/null
+++ b/tests/unit_tests/runtime/path_composer_rebuild_test.cpp
@@ -0,0 +1,311 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// PathComposer keeps a shape's local path when nothing it is built from has
+// changed. Its inputs are each path's geometry and each path's transform
+// relative to the shape (inverseWorld * pathTransform), and that relative
+// transform survives any rigid move of the shape -- an ancestor translating,
+// rotating or scaling moves the shape and its paths together, so the local
+// path it would rebuild is the one it already holds. Skipping that rebuild is
+// what lets the RenderPath keep its cached triangulation.
+//
+// The contract this pins down is that the retained path stays equal to a fresh
+// rebuild. It cannot be bit equality: the composed transform is an A^-1 * A
+// round trip, so recomputing it after a move lands a rounding step away from
+// the one the buffer was built with. So: the verb stream must match exactly,
+// and points must agree to within a few ULPs of their own magnitude.
+
+#include "rive/file.hpp"
+#include "rive/math/math_types.hpp"
+#include "rive/shapes/path.hpp"
+#include "rive/shapes/path_composer.hpp"
+#include "rive/shapes/shape.hpp"
+#include "rive/shapes/shape_paint_path.hpp"
+#include "rive/animation/state_machine_instance.hpp"
+#include "rive_file_reader.hpp"
+#include "rive_testing.hpp"
+#include <cmath>
+#include <limits>
+#include <vector>
+
+using namespace rive;
+
+namespace
+{
+// Real files, chosen to cover what feeds the composer: plain fills, skins,
+// solos, clips, trim paths and follow paths.
+const char* kFiles[] = {
+    "assets/car_widgets_v01.riv",
+    "assets/zombie_skins.riv",
+    "assets/echo_show_demo.riv",
+    "assets/jellyfish_test.riv",
+    "assets/trim_path.riv",
+    "assets/fill_trim_path.riv",
+    "assets/follow_path.riv",
+    "assets/follow_path_shapes.riv",
+    "assets/follow_path_solos.riv",
+    "assets/solo_test.riv",
+    "assets/clip_tests.riv",
+    "assets/clipping_and_draw_order.riv",
+};
+
+constexpr int kFrames = 120;
+
+// Force every path to rebuild its geometry, which is an input the composer
+// cannot skip on, and settle the artboard. Whatever the composer holds
+// afterwards is a fresh build by its own code rather than a reimplementation
+// of it here.
+void forceRebuild(Artboard* artboard)
+{
+    for (auto shape : artboard->find<Shape>())
+    {
+        for (auto path : shape->paths())
+        {
+            path->markPathDirty();
+        }
+    }
+    artboard->advance(0.0f);
+}
+
+// How far a point is allowed to sit from a freshly rebuilt one. The error is
+// the rounding of recomposing inverseWorld * pathTransform, so it scales with
+// the world coordinates that go through that cancellation -- not with the
+// local coordinate it lands on.
+double allowedDelta(Shape* shape)
+{
+    const Mat2D& world = shape->worldTransform();
+    double scale =
+        std::max({1.0, (double)std::abs(world[4]), (double)std::abs(world[5])});
+    for (auto path : shape->paths())
+    {
+        const Mat2D& pathWorld = path->pathTransform();
+        scale = std::max({scale,
+                          (double)std::abs(pathWorld[4]),
+                          (double)std::abs(pathWorld[5])});
+    }
+    return 16.0 * std::numeric_limits<float>::epsilon() * scale;
+}
+
+// Drive one frame: animate, and move the whole artboard rigidly so every
+// shape's world transform changes without any of them deforming.
+void advanceFrame(Artboard* artboard, StateMachineInstance* machine, int i)
+{
+    artboard->mutableWorldTransform() =
+        Mat2D::fromRotation((float)i * 0.013f) *
+        Mat2D::fromTranslate((float)(i % 23) * 1.7f, (float)(i % 7));
+    artboard->markWorldTransformDirty();
+    if (machine != nullptr)
+    {
+        machine->pointerMove(
+            Vec2D((float)(i * 13 % 500), (float)(i * 29 % 500)));
+        machine->advanceAndApply(1.0f / 60.0f);
+    }
+    else
+    {
+        artboard->advance(1.0f / 60.0f);
+    }
+}
+
+// Shapes whose path update is deferred (transparent and not clipping) hold a
+// stale path by design, and n-sliced shapes are fed by their slicer rather
+// than by the loop above.
+bool isComparable(Shape* shape)
+{
+    return shape->isFlagged(PathFlags::local) && !shape->canDeferPathUpdate() &&
+           !shape->isFlagged(PathFlags::followPath);
+}
+} // namespace
+
+TEST_CASE("a retained local path equals a fresh rebuild", "[path_composer]")
+{
+    for (auto name : kFiles)
+    {
+        auto file = ReadRiveFile(name);
+        auto artboard = file->artboard()->instance();
+        auto machine = artboard->defaultStateMachine();
+        artboard->advance(0.0f);
+
+        size_t verbMismatches = 0;
+        size_t violations = 0;
+        double worstDelta = 0.0;
+        double worstAllowed = 0.0;
+
+        for (int i = 0; i < kFrames; i++)
+        {
+            advanceFrame(artboard.get(), machine.get(), i);
+
+            // What the composer is handing the renderer this frame.
+            std::vector<Shape*> shapes;
+            std::vector<RawPath> held;
+            for (auto shape : artboard->find<Shape>())
+            {
+                if (isComparable(shape))
+                {
+                    shapes.push_back(shape);
+                    held.push_back(
+                        *shape->pathComposer()->localPath()->rawPath());
+                }
+            }
+
+            forceRebuild(artboard.get());
+
+            for (size_t s = 0; s < shapes.size(); s++)
+            {
+                const RawPath& fresh =
+                    *shapes[s]->pathComposer()->localPath()->rawPath();
+                const RawPath& kept = held[s];
+                if (kept.verbs().count() != fresh.verbs().count() ||
+                    kept.points().count() != fresh.points().count())
+                {
+                    verbMismatches++;
+                    continue;
+                }
+                for (size_t v = 0; v < kept.verbs().count(); v++)
+                {
+                    if (kept.verbs()[v] != fresh.verbs()[v])
+                    {
+                        verbMismatches++;
+                        break;
+                    }
+                }
+                const double allowed = allowedDelta(shapes[s]);
+                for (size_t p = 0; p < kept.points().count(); p++)
+                {
+                    const Vec2D a = kept.points()[p];
+                    const Vec2D b = fresh.points()[p];
+                    const double delta =
+                        std::max(std::abs((double)a.x - (double)b.x),
+                                 std::abs((double)a.y - (double)b.y));
+                    if (delta > allowed)
+                    {
+                        violations++;
+                    }
+                    if (delta > worstDelta)
+                    {
+                        worstDelta = delta;
+                        worstAllowed = allowed;
+                    }
+                }
+            }
+        }
+
+        INFO("file " << name << ", worst point delta " << worstDelta
+                     << " against an allowance of " << worstAllowed);
+        CHECK(verbMismatches == 0);
+        CHECK(violations == 0);
+    }
+}
+
+// The above passes trivially if the composer rebuilds every frame, so pin down
+// that it actually skips: a rigidly moved shape keeps the exact points it was
+// built with, which a rebuild would have replaced with recomposed ones.
+TEST_CASE("a rigid move retains the local path it already built",
+          "[path_composer]")
+{
+    auto file = ReadRiveFile("assets/car_widgets_v01.riv");
+    auto artboard = file->artboard()->instance();
+    artboard->advance(0.0f);
+
+    Shape* moved = nullptr;
+    std::vector<Vec2D> before;
+    for (auto shape : artboard->find<Shape>())
+    {
+        if (isComparable(shape) &&
+            !shape->pathComposer()->localPath()->rawPath()->empty())
+        {
+            moved = shape;
+            auto points =
+                shape->pathComposer()->localPath()->rawPath()->points();
+            before.assign(points.begin(), points.end());
+            break;
+        }
+    }
+    REQUIRE(moved != nullptr);
+    REQUIRE(!before.empty());
+
+    // Rotate, scale and translate the artboard. None of that changes any
+    // path's transform relative to its shape.
+    artboard->mutableWorldTransform() = Mat2D::fromRotation(math::PI / 3.0f) *
+                                        Mat2D::fromScale(2.0f, 0.5f) *
+                                        Mat2D::fromTranslate(137.0f, -42.0f);
+    artboard->markWorldTransformDirty();
+    artboard->advance(0.0f);
+
+    auto after = moved->pathComposer()->localPath()->rawPath()->points();
+    REQUIRE(after.count() == before.size());
+    for (size_t i = 0; i < before.size(); i++)
+    {
+        // Bit-identical: the buffer was never touched.
+        CHECK(after[i].x == before[i].x);
+        CHECK(after[i].y == before[i].y);
+    }
+}
+
+// The dangerous case for a skip: a path moves relative to its shape without
+// its geometry changing. Nothing rebuilds its raw path -- only the transform
+// composed into the local path changes -- so a skip that watched geometry
+// alone would keep drawing the path where it used to be.
+TEST_CASE("a path moving inside its shape rebuilds the local path",
+          "[path_composer]")
+{
+    auto file = ReadRiveFile("assets/car_widgets_v01.riv");
+    auto artboard = file->artboard()->instance();
+    artboard->advance(0.0f);
+
+    Shape* shape = nullptr;
+    for (auto candidate : artboard->find<Shape>())
+    {
+        if (isComparable(candidate) && !candidate->paths().empty() &&
+            !candidate->pathComposer()->localPath()->rawPath()->empty())
+        {
+            shape = candidate;
+            break;
+        }
+    }
+    REQUIRE(shape != nullptr);
+    auto path = shape->paths()[0];
+
+    auto points = shape->pathComposer()->localPath()->rawPath()->points();
+    std::vector<Vec2D> before(points.begin(), points.end());
+    const uint32_t geometryVersion = path->geometryVersion();
+
+    // Move the path within the shape. This is a transform change, so the
+    // path's own geometry is untouched...
+    path->x(path->x() + 10.0f);
+    path->markTransformDirty();
+    artboard->advance(0.0f);
+    CHECK(path->geometryVersion() == geometryVersion);
+
+    // ...but the shape's local path has to follow it.
+    auto moved = shape->pathComposer()->localPath()->rawPath()->points();
+    REQUIRE(moved.count() == before.size());
+    bool anyMoved = false;
+    for (size_t i = 0; i < before.size(); i++)
+    {
+        if (moved[i].x != before[i].x)
+        {
+            anyMoved = true;
+            break;
+        }
+    }
+    CHECK(anyMoved);
+
+    // And it has to agree with a rebuild, point for point.
+    std::vector<Vec2D> held(moved.begin(), moved.end());
+    forceRebuild(artboard.get());
+    auto fresh = shape->pathComposer()->localPath()->rawPath()->points();
+    REQUIRE(fresh.count() == held.size());
+    const double allowed = allowedDelta(shape);
+    size_t violations = 0;
+    for (size_t i = 0; i < held.size(); i++)
+    {
+        if (std::max(std::abs((double)held[i].x - (double)fresh[i].x),
+                     std::abs((double)held[i].y - (double)fresh[i].y)) >
+            allowed)
+        {
+            violations++;
+        }
+    }
+    CHECK(violations == 0);
+}
diff --git a/tests/unit_tests/runtime/silver_visual_diff.cpp b/tests/unit_tests/runtime/silver_visual_diff.cpp
new file mode 100644
index 0000000..584fe8d
--- /dev/null
+++ b/tests/unit_tests/runtime/silver_visual_diff.cpp
@@ -0,0 +1,539 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Renders a tarnished SRIV next to its baseline and reports whether the two
+// actually differ on screen.
+//
+// A silver test fails on any byte difference in the recorded command stream,
+// which is stricter than "the frame changed": dropping a redundant rewind and
+// addRawPath pair, or reordering equivalent calls, moves those bytes without
+// moving a pixel. This replays both streams through the same renderer and
+// compares the frames, so a rebaseline can be justified rather than trusted.
+//
+// Hidden by default (the leading dot in the tag). Run it directly against the
+// built binary, NOT through test.sh, which wipes silvers/tarnished on startup:
+//
+//     ./out/release/unit_tests "[.silverdiff]"
+//
+// SILVER_DIFF_NAME=<silver name> limits it to one. Differing frames are
+// written to silvers/tarnished/diff/ as baseline, tarnished and a difference
+// image.
+
+#include "rive/rive_types.hpp"
+#include "rive_testing.hpp"
+
+#include <cstdio>
+#include <cstdlib>
+#include <string>
+#include <vector>
+
+#if defined(RIVE_MACOSX) && !defined(RIVE_NO_FILESYSTEM)
+
+#include "common/write_png_file.hpp"
+#include "cg_factory.hpp"
+#include "cg_renderer.hpp"
+#include "utils/serialized_replay.hpp"
+
+#include <CoreGraphics/CoreGraphics.h>
+#include <algorithm>
+#include <cstring>
+#include <filesystem>
+#include <set>
+
+using namespace rive;
+
+namespace
+{
+constexpr uint32_t kClearColor = 0x00000000;
+
+std::vector<uint8_t> readFile(const std::string& path)
+{
+    std::vector<uint8_t> bytes;
+    FILE* fp = fopen(path.c_str(), "rb");
+    if (fp == nullptr)
+    {
+        return bytes;
+    }
+    fseek(fp, 0, SEEK_END);
+    bytes.resize((size_t)ftell(fp));
+    fseek(fp, 0, SEEK_SET);
+    if (fread(bytes.data(), 1, bytes.size(), fp) != bytes.size())
+    {
+        bytes.clear();
+    }
+    fclose(fp);
+    return bytes;
+}
+
+uint64_t hashPixels(const std::vector<uint32_t>& pixels)
+{
+    uint64_t hash = 0xcbf29ce484222325ull;
+    for (uint32_t pixel : pixels)
+    {
+        hash = (hash ^ pixel) * 0x100000001b3ull;
+    }
+    return hash;
+}
+
+// One replay of a stream into a CoreGraphics bitmap. The renderer is CPU and
+// deterministic, which is all this needs: both sides go through it, so the
+// comparison is exact even where the rasterizer is approximate.
+struct Replay
+{
+    // Frames whose pixels the caller wants kept. Everything else is hashed and
+    // dropped -- a 2370x1680 stream is 16MB a frame.
+    std::set<int> keep;
+
+    std::vector<uint64_t> hashes;
+    std::vector<std::vector<uint32_t>> kept;
+    uint32_t width = 0;
+    uint32_t height = 0;
+    int resizes = 0;
+    bool ok = false;
+
+    bool run(const std::vector<uint8_t>& stream)
+    {
+        CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB();
+        CGContextRef ctx = nullptr;
+        std::vector<uint32_t> pixels;
+        std::unique_ptr<CGRenderer> renderer;
+        CGFactory factory;
+
+        auto clear = [&pixels]() {
+            std::fill(pixels.begin(), pixels.end(), kClearColor);
+        };
+
+        SerializedReplayHooks hooks;
+        hooks.onFrameSize = [&](uint32_t w, uint32_t h) {
+            if (w == width && h == height && ctx != nullptr)
+            {
+                return;
+            }
+            resizes++;
+            width = w;
+            height = h;
+            pixels.assign((size_t)w * h, kClearColor);
+            // ~CGRenderer restores a graphics state on its context, so the
+            // renderer has to go first or it restores through a freed one.
+            renderer.reset();
+            if (ctx != nullptr)
+            {
+                CGContextRelease(ctx);
+            }
+            const uint32_t info =
+                static_cast<uint32_t>(kCGBitmapByteOrder32Big) |
+                static_cast<uint32_t>(kCGImageAlphaPremultipliedLast);
+            ctx = CGBitmapContextCreate(pixels.data(),
+                                        w,
+                                        h,
+                                        8,
+                                        w * 4,
+                                        space,
+                                        info);
+            renderer = std::make_unique<CGRenderer>(ctx, (int)w, (int)h);
+        };
+        hooks.onFrame = [&]() {
+            if (ctx == nullptr)
+            {
+                return;
+            }
+            CGContextFlush(ctx);
+            const int index = (int)hashes.size();
+            hashes.push_back(hashPixels(pixels));
+            if (keep.count(index) != 0)
+            {
+                kept.push_back(pixels);
+            }
+            clear();
+        };
+
+        // The replayer takes one renderer for the whole stream, so the first
+        // frameSize has to arrive before any drawing. Silvers emit it up front.
+        Replay* self = this;
+        (void)self;
+        bool result = false;
+        {
+            // A renderer is needed up front; the stream's first op is the
+            // frame size, which builds it. Until then draws have nowhere to
+            // go, so replay in two steps: peek the size, then replay for real.
+            std::vector<uint32_t> probePixels(1);
+            const uint32_t info =
+                static_cast<uint32_t>(kCGBitmapByteOrder32Big) |
+                static_cast<uint32_t>(kCGImageAlphaPremultipliedLast);
+            CGContextRef probeCtx = CGBitmapContextCreate(probePixels.data(),
+                                                          1,
+                                                          1,
+                                                          8,
+                                                          4,
+                                                          space,
+                                                          info);
+            uint32_t w = 0, h = 0;
+            {
+                // Scoped so the probe renderer is destroyed, and restores its
+                // graphics state, before the context it holds is released.
+                CGRenderer probeRenderer(probeCtx, 1, 1);
+                SerializedReplayHooks sizeOnly;
+                sizeOnly.onFrameSize = [&w, &h](uint32_t width,
+                                                uint32_t height) {
+                    if (w == 0)
+                    {
+                        w = width;
+                        h = height;
+                    }
+                };
+                replaySerializedCommands(
+                    Span<const uint8_t>(stream.data(), stream.size()),
+                    &factory,
+                    &probeRenderer,
+                    sizeOnly);
+            }
+            CGContextRelease(probeCtx);
+            if (w == 0 || h == 0)
+            {
+                CGColorSpaceRelease(space);
+                return false;
+            }
+            hooks.onFrameSize(w, h);
+            result = replaySerializedCommands(
+                Span<const uint8_t>(stream.data(), stream.size()),
+                &factory,
+                renderer.get(),
+                hooks);
+        }
+
+        renderer.reset();
+        if (ctx != nullptr)
+        {
+            CGContextRelease(ctx);
+        }
+        CGColorSpaceRelease(space);
+        ok = result;
+        return result;
+    }
+};
+
+void writePNG(const std::string& path,
+              std::vector<uint32_t> pixels,
+              uint32_t w,
+              uint32_t h)
+{
+    WritePNGFile(reinterpret_cast<uint8_t*>(pixels.data()),
+                 (int)w,
+                 (int)h,
+                 false,
+                 path.c_str(),
+                 PNGCompression::fast_rle);
+}
+
+// A raw difference of a few units out of 255 is invisible, and these are
+// needles in a 4 megapixel haystack, so mark every differing pixel in red at
+// an intensity that says how far off it was. Black means identical.
+std::vector<uint32_t> differenceImage(const std::vector<uint32_t>& a,
+                                      const std::vector<uint32_t>& b)
+{
+    std::vector<uint32_t> out(a.size());
+    for (size_t i = 0; i < a.size(); i++)
+    {
+        const uint8_t* pa = reinterpret_cast<const uint8_t*>(&a[i]);
+        const uint8_t* pb = reinterpret_cast<const uint8_t*>(&b[i]);
+        uint8_t* po = reinterpret_cast<uint8_t*>(&out[i]);
+        int worst = 0;
+        for (int c = 0; c < 4; c++)
+        {
+            worst = std::max(worst, std::abs((int)pa[c] - (int)pb[c]));
+        }
+        po[0] = worst == 0 ? 0 : (uint8_t)std::min(255, 96 + worst * 8);
+        po[1] = 0;
+        po[2] = 0;
+        po[3] = 0xff;
+    }
+    return out;
+}
+
+struct FrameDiff
+{
+    size_t differingPixels = 0;
+    int maxChannelDelta = 0;
+    // Bounding box of the differing pixels, so the report says where.
+    uint32_t minX = UINT32_MAX, minY = UINT32_MAX, maxX = 0, maxY = 0;
+    bool any() const { return differingPixels != 0; }
+};
+
+FrameDiff compareFrames(const std::vector<uint32_t>& a,
+                        const std::vector<uint32_t>& b,
+                        uint32_t width)
+{
+    FrameDiff diff;
+    for (size_t i = 0; i < a.size(); i++)
+    {
+        if (a[i] == b[i])
+        {
+            continue;
+        }
+        diff.differingPixels++;
+        const uint32_t x = (uint32_t)(i % width);
+        const uint32_t y = (uint32_t)(i / width);
+        diff.minX = std::min(diff.minX, x);
+        diff.minY = std::min(diff.minY, y);
+        diff.maxX = std::max(diff.maxX, x);
+        diff.maxY = std::max(diff.maxY, y);
+        const uint8_t* pa = reinterpret_cast<const uint8_t*>(&a[i]);
+        const uint8_t* pb = reinterpret_cast<const uint8_t*>(&b[i]);
+        for (int c = 0; c < 4; c++)
+        {
+            diff.maxChannelDelta = std::max(diff.maxChannelDelta,
+                                            std::abs((int)pa[c] - (int)pb[c]));
+        }
+    }
+    return diff;
+}
+
+// A close-up of the region that differs, padded so there is context around it.
+// Without this the evidence is a handful of pixels in a 4 megapixel frame.
+std::vector<uint32_t> crop(const std::vector<uint32_t>& pixels,
+                           uint32_t width,
+                           uint32_t height,
+                           uint32_t x0,
+                           uint32_t y0,
+                           uint32_t w,
+                           uint32_t h)
+{
+    std::vector<uint32_t> out((size_t)w * h, 0xff000000);
+    for (uint32_t y = 0; y < h; y++)
+    {
+        if (y0 + y >= height)
+        {
+            break;
+        }
+        for (uint32_t x = 0; x < w; x++)
+        {
+            if (x0 + x >= width)
+            {
+                break;
+            }
+            out[(size_t)y * w + x] =
+                pixels[(size_t)(y0 + y) * width + (x0 + x)];
+        }
+    }
+    return out;
+}
+
+} // namespace
+
+TEST_CASE("tarnished silvers render the same as their baselines",
+          "[.silverdiff]")
+{
+    const std::string tarnishedDir = "silvers/tarnished/";
+    const std::string outDir = tarnishedDir + "diff/";
+    if (!std::filesystem::exists(tarnishedDir))
+    {
+        WARN("no silvers/tarnished directory -- run the suite first, and note "
+             "that test.sh wipes it on startup");
+        return;
+    }
+
+    const char* only = getenv("SILVER_DIFF_NAME");
+    std::vector<std::string> names;
+    for (auto& entry : std::filesystem::directory_iterator(tarnishedDir))
+    {
+        if (entry.path().extension() != ".sriv")
+        {
+            continue;
+        }
+        const std::string name = entry.path().stem().string();
+        if (only == nullptr || name == only)
+        {
+            names.push_back(name);
+        }
+    }
+    std::sort(names.begin(), names.end());
+    if (names.empty())
+    {
+        WARN("no tarnished silvers to compare");
+        return;
+    }
+
+    size_t identical = 0, differing = 0, unreadable = 0;
+    size_t worstPixelsOverall = 0;
+    int worstChannelOverall = 0;
+    for (const auto& name : names)
+    {
+        auto baselineBytes = readFile("silvers/" + name + ".sriv");
+        auto tarnishedBytes = readFile(tarnishedDir + name + ".sriv");
+        if (baselineBytes.empty() || tarnishedBytes.empty())
+        {
+            fprintf(stderr,
+                    "[silverdiff] %-48s MISSING BASELINE\n",
+                    name.c_str());
+            unreadable++;
+            // Nothing was compared, so this cannot count as a pass.
+            CHECK(false);
+            continue;
+        }
+
+        Replay baseline, tarnished;
+        const bool replayed =
+            baseline.run(baselineBytes) && tarnished.run(tarnishedBytes);
+        if (!replayed)
+        {
+            fprintf(stderr, "[silverdiff] %-48s REPLAY FAILED\n", name.c_str());
+            unreadable++;
+            CHECK(replayed);
+            continue;
+        }
+
+        if (baseline.width != tarnished.width ||
+            baseline.height != tarnished.height ||
+            baseline.hashes.size() != tarnished.hashes.size())
+        {
+            fprintf(stderr,
+                    "[silverdiff] %-48s SHAPE DIFFERS %ux%u/%zu frames vs "
+                    "%ux%u/%zu\n",
+                    name.c_str(),
+                    baseline.width,
+                    baseline.height,
+                    baseline.hashes.size(),
+                    tarnished.width,
+                    tarnished.height,
+                    tarnished.hashes.size());
+            differing++;
+            CHECK(false);
+            continue;
+        }
+
+        std::set<int> mismatched;
+        for (size_t i = 0; i < baseline.hashes.size(); i++)
+        {
+            if (baseline.hashes[i] != tarnished.hashes[i])
+            {
+                mismatched.insert((int)i);
+            }
+        }
+        if (mismatched.empty())
+        {
+            fprintf(stderr,
+                    "[silverdiff] %-48s identical (%zu frames, %ux%u)\n",
+                    name.c_str(),
+                    baseline.hashes.size(),
+                    baseline.width,
+                    baseline.height);
+            identical++;
+            continue;
+        }
+
+        // Second pass over the frames that differ, this time keeping pixels.
+        std::set<int> keep;
+        for (int index : mismatched)
+        {
+            if (keep.size() >= 3)
+            {
+                break;
+            }
+            keep.insert(index);
+        }
+        Replay baselinePixels, tarnishedPixels;
+        baselinePixels.keep = keep;
+        tarnishedPixels.keep = keep;
+        baselinePixels.run(baselineBytes);
+        tarnishedPixels.run(tarnishedBytes);
+
+        std::filesystem::create_directories(outDir);
+        size_t worstPixels = 0;
+        int worstChannel = 0;
+        size_t k = 0;
+        for (int index : keep)
+        {
+            if (k >= baselinePixels.kept.size() ||
+                k >= tarnishedPixels.kept.size())
+            {
+                break;
+            }
+            const auto& a = baselinePixels.kept[k];
+            const auto& b = tarnishedPixels.kept[k];
+            const FrameDiff diff = compareFrames(a, b, baseline.width);
+            worstPixels = std::max(worstPixels, diff.differingPixels);
+            worstChannel = std::max(worstChannel, diff.maxChannelDelta);
+
+            const std::string stem =
+                outDir + name + "-frame" + std::to_string(index);
+            writePNG(stem + "-baseline.png",
+                     a,
+                     baseline.width,
+                     baseline.height);
+            writePNG(stem + "-tarnished.png",
+                     b,
+                     baseline.width,
+                     baseline.height);
+            writePNG(stem + "-diff.png",
+                     differenceImage(a, b),
+                     baseline.width,
+                     baseline.height);
+
+            if (diff.any())
+            {
+                // Pad the differing region out to at least 192px so there is
+                // something recognizable around it.
+                constexpr uint32_t kMin = 192;
+                uint32_t w = std::max(kMin, diff.maxX - diff.minX + 1);
+                uint32_t h = std::max(kMin, diff.maxY - diff.minY + 1);
+                uint32_t cx = (diff.minX + diff.maxX) / 2;
+                uint32_t cy = (diff.minY + diff.maxY) / 2;
+                uint32_t x0 = cx > w / 2 ? cx - w / 2 : 0;
+                uint32_t y0 = cy > h / 2 ? cy - h / 2 : 0;
+                writePNG(stem + "-crop-baseline.png",
+                         crop(a, baseline.width, baseline.height, x0, y0, w, h),
+                         w,
+                         h);
+                writePNG(stem + "-crop-tarnished.png",
+                         crop(b, baseline.width, baseline.height, x0, y0, w, h),
+                         w,
+                         h);
+                fprintf(stderr,
+                        "               frame %d: %zu pixels differ, max "
+                        "channel delta %d, within (%u,%u)-(%u,%u)\n",
+                        index,
+                        diff.differingPixels,
+                        diff.maxChannelDelta,
+                        diff.minX,
+                        diff.minY,
+                        diff.maxX,
+                        diff.maxY);
+            }
+            k++;
+        }
+
+        fprintf(stderr,
+                "[silverdiff] %-48s DIFFERS on %zu/%zu frames, worst frame %zu "
+                "pixels, max channel delta %d -> %s\n",
+                name.c_str(),
+                mismatched.size(),
+                baseline.hashes.size(),
+                worstPixels,
+                worstChannel,
+                outDir.c_str());
+        worstPixelsOverall = std::max(worstPixelsOverall, worstPixels);
+        worstChannelOverall = std::max(worstChannelOverall, worstChannel);
+        differing++;
+        CHECK(mismatched.empty());
+    }
+
+    fprintf(stderr,
+            "[silverdiff] %zu identical, %zu differing, %zu unreadable. Worst "
+            "frame anywhere: %zu pixels, max channel delta %d/255.\n",
+            identical,
+            differing,
+            unreadable,
+            worstPixelsOverall,
+            worstChannelOverall);
+}
+
+#else
+
+TEST_CASE("tarnished silvers render the same as their baselines",
+          "[.silverdiff]")
+{
+    WARN("silver visual diff needs macOS (CoreGraphics) and std::filesystem");
+}
+
+#endif
diff --git a/tests/unit_tests/silvers/artboard_list_overrides_horizontal.sriv b/tests/unit_tests/silvers/artboard_list_overrides_horizontal.sriv
index d58a08f..f7427bb 100644
--- a/tests/unit_tests/silvers/artboard_list_overrides_horizontal.sriv
+++ b/tests/unit_tests/silvers/artboard_list_overrides_horizontal.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/artboard_list_overrides_vertical.sriv b/tests/unit_tests/silvers/artboard_list_overrides_vertical.sriv
index 644d267..d2c61a8 100644
--- a/tests/unit_tests/silvers/artboard_list_overrides_vertical.sriv
+++ b/tests/unit_tests/silvers/artboard_list_overrides_vertical.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/artboard_opacity_and_transform_test.sriv b/tests/unit_tests/silvers/artboard_opacity_and_transform_test.sriv
index ca2602d..089d6bd 100644
--- a/tests/unit_tests/silvers/artboard_opacity_and_transform_test.sriv
+++ b/tests/unit_tests/silvers/artboard_opacity_and_transform_test.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/bankcard.sriv b/tests/unit_tests/silvers/bankcard.sriv
index 0604aa1..aeb2bf9 100644
--- a/tests/unit_tests/silvers/bankcard.sriv
+++ b/tests/unit_tests/silvers/bankcard.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/bidirectional_stateful_property.sriv b/tests/unit_tests/silvers/bidirectional_stateful_property.sriv
index df86760..0c945d7 100644
--- a/tests/unit_tests/silvers/bidirectional_stateful_property.sriv
+++ b/tests/unit_tests/silvers/bidirectional_stateful_property.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/car_widgets_v01.sriv b/tests/unit_tests/silvers/car_widgets_v01.sriv
index d771d52..afada96 100644
--- a/tests/unit_tests/silvers/car_widgets_v01.sriv
+++ b/tests/unit_tests/silvers/car_widgets_v01.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/collapsable_data_binding.sriv b/tests/unit_tests/silvers/collapsable_data_binding.sriv
index 60ddccc..883a76e 100644
--- a/tests/unit_tests/silvers/collapsable_data_binding.sriv
+++ b/tests/unit_tests/silvers/collapsable_data_binding.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/component_based_conditions.sriv b/tests/unit_tests/silvers/component_based_conditions.sriv
index 5c75363..f51aa7d 100644
--- a/tests/unit_tests/silvers/component_based_conditions.sriv
+++ b/tests/unit_tests/silvers/component_based_conditions.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/component_list_follow_path.sriv b/tests/unit_tests/silvers/component_list_follow_path.sriv
index 82f7fb9..c8de3c5 100644
--- a/tests/unit_tests/silvers/component_list_follow_path.sriv
+++ b/tests/unit_tests/silvers/component_list_follow_path.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/component_list_grouped.sriv b/tests/unit_tests/silvers/component_list_grouped.sriv
index 6d7e6d6..34af986 100644
--- a/tests/unit_tests/silvers/component_list_grouped.sriv
+++ b/tests/unit_tests/silvers/component_list_grouped.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/component_stateful_vm_instance_2.sriv b/tests/unit_tests/silvers/component_stateful_vm_instance_2.sriv
index 13c2afc..dae40ab 100644
--- a/tests/unit_tests/silvers/component_stateful_vm_instance_2.sriv
+++ b/tests/unit_tests/silvers/component_stateful_vm_instance_2.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/computed_root_transform-list.sriv b/tests/unit_tests/silvers/computed_root_transform-list.sriv
index 2078c62..3beee85 100644
--- a/tests/unit_tests/silvers/computed_root_transform-list.sriv
+++ b/tests/unit_tests/silvers/computed_root_transform-list.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/computed_root_transform-nested_artboard.sriv b/tests/unit_tests/silvers/computed_root_transform-nested_artboard.sriv
index 4c92955..f8a44db 100644
--- a/tests/unit_tests/silvers/computed_root_transform-nested_artboard.sriv
+++ b/tests/unit_tests/silvers/computed_root_transform-nested_artboard.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/computed_values_test.sriv b/tests/unit_tests/silvers/computed_values_test.sriv
index 38c55bc..37532a7 100644
--- a/tests/unit_tests/silvers/computed_values_test.sriv
+++ b/tests/unit_tests/silvers/computed_values_test.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/data_bind_keyframes_test.sriv b/tests/unit_tests/silvers/data_bind_keyframes_test.sriv
index 820ff45..433df8f 100644
--- a/tests/unit_tests/silvers/data_bind_keyframes_test.sriv
+++ b/tests/unit_tests/silvers/data_bind_keyframes_test.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/data_binding_artboards_default_test.sriv b/tests/unit_tests/silvers/data_binding_artboards_default_test.sriv
index eac3088..a2af302 100644
--- a/tests/unit_tests/silvers/data_binding_artboards_default_test.sriv
+++ b/tests/unit_tests/silvers/data_binding_artboards_default_test.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/data_binding_artboards_test.sriv b/tests/unit_tests/silvers/data_binding_artboards_test.sriv
index 63c75de..6c29e25 100644
--- a/tests/unit_tests/silvers/data_binding_artboards_test.sriv
+++ b/tests/unit_tests/silvers/data_binding_artboards_test.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/data_converter_interpolator_reset.sriv b/tests/unit_tests/silvers/data_converter_interpolator_reset.sriv
index 1f8092d..cdf679e 100644
--- a/tests/unit_tests/silvers/data_converter_interpolator_reset.sriv
+++ b/tests/unit_tests/silvers/data_converter_interpolator_reset.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/data_viz_demo.sriv b/tests/unit_tests/silvers/data_viz_demo.sriv
index 3198c38..f0a90bd 100644
--- a/tests/unit_tests/silvers/data_viz_demo.sriv
+++ b/tests/unit_tests/silvers/data_viz_demo.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/db_health_tracker.sriv b/tests/unit_tests/silvers/db_health_tracker.sriv
index 92de449..ae5309c 100644
--- a/tests/unit_tests/silvers/db_health_tracker.sriv
+++ b/tests/unit_tests/silvers/db_health_tracker.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/drag_event.sriv b/tests/unit_tests/silvers/drag_event.sriv
index 384eb0b..1c15ec3 100644
--- a/tests/unit_tests/silvers/drag_event.sriv
+++ b/tests/unit_tests/silvers/drag_event.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/draw_index_list.sriv b/tests/unit_tests/silvers/draw_index_list.sriv
index 86a2aa4..3608fe5 100644
--- a/tests/unit_tests/silvers/draw_index_list.sriv
+++ b/tests/unit_tests/silvers/draw_index_list.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/echo_show_demo.sriv b/tests/unit_tests/silvers/echo_show_demo.sriv
index 33280bf..61ba2ed 100644
--- a/tests/unit_tests/silvers/echo_show_demo.sriv
+++ b/tests/unit_tests/silvers/echo_show_demo.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/follow_path_animate_shape.sriv b/tests/unit_tests/silvers/follow_path_animate_shape.sriv
index c7f8def..77afaec 100644
--- a/tests/unit_tests/silvers/follow_path_animate_shape.sriv
+++ b/tests/unit_tests/silvers/follow_path_animate_shape.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/follow_path_animate_solo.sriv b/tests/unit_tests/silvers/follow_path_animate_solo.sriv
index 4d96699..bb61bae 100644
--- a/tests/unit_tests/silvers/follow_path_animate_solo.sriv
+++ b/tests/unit_tests/silvers/follow_path_animate_solo.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/follow_path_animate_target.sriv b/tests/unit_tests/silvers/follow_path_animate_target.sriv
index f540fd3..ab30524 100644
--- a/tests/unit_tests/silvers/follow_path_animate_target.sriv
+++ b/tests/unit_tests/silvers/follow_path_animate_target.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/follow_path_constraint.sriv b/tests/unit_tests/silvers/follow_path_constraint.sriv
index 5a79814..c6ca73c 100644
--- a/tests/unit_tests/silvers/follow_path_constraint.sriv
+++ b/tests/unit_tests/silvers/follow_path_constraint.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/formula_random-always.sriv b/tests/unit_tests/silvers/formula_random-always.sriv
index 1f77802..9095ab8 100644
--- a/tests/unit_tests/silvers/formula_random-always.sriv
+++ b/tests/unit_tests/silvers/formula_random-always.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/formula_random-once.sriv b/tests/unit_tests/silvers/formula_random-once.sriv
index a7dde87..c21c7a8 100644
--- a/tests/unit_tests/silvers/formula_random-once.sriv
+++ b/tests/unit_tests/silvers/formula_random-once.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/formula_random-source_change.sriv b/tests/unit_tests/silvers/formula_random-source_change.sriv
index b5c2174..484ebc4 100644
--- a/tests/unit_tests/silvers/formula_random-source_change.sriv
+++ b/tests/unit_tests/silvers/formula_random-source_change.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/game_menu_ad_police_files.sriv b/tests/unit_tests/silvers/game_menu_ad_police_files.sriv
index 1815cce..8330299 100644
--- a/tests/unit_tests/silvers/game_menu_ad_police_files.sriv
+++ b/tests/unit_tests/silvers/game_menu_ad_police_files.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/gamepad_test.sriv b/tests/unit_tests/silvers/gamepad_test.sriv
index 5505d9c..73ab2c1 100644
--- a/tests/unit_tests/silvers/gamepad_test.sriv
+++ b/tests/unit_tests/silvers/gamepad_test.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/hittest_ab_2_non_virtualized.sriv b/tests/unit_tests/silvers/hittest_ab_2_non_virtualized.sriv
index 9d9ceb1..2231ea7 100644
--- a/tests/unit_tests/silvers/hittest_ab_2_non_virtualized.sriv
+++ b/tests/unit_tests/silvers/hittest_ab_2_non_virtualized.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/hittest_ab_2_virtualized.sriv b/tests/unit_tests/silvers/hittest_ab_2_virtualized.sriv
index 6ec0b34..1e86d58 100644
--- a/tests/unit_tests/silvers/hittest_ab_2_virtualized.sriv
+++ b/tests/unit_tests/silvers/hittest_ab_2_virtualized.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/hittest_ab_shape_parent.sriv b/tests/unit_tests/silvers/hittest_ab_shape_parent.sriv
index c44ff3c..d5be453 100644
--- a/tests/unit_tests/silvers/hittest_ab_shape_parent.sriv
+++ b/tests/unit_tests/silvers/hittest_ab_shape_parent.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/hittest_ab_text_parent.sriv b/tests/unit_tests/silvers/hittest_ab_text_parent.sriv
index 859575c..f71548f 100644
--- a/tests/unit_tests/silvers/hittest_ab_text_parent.sriv
+++ b/tests/unit_tests/silvers/hittest_ab_text_parent.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/hunter_x_demo.sriv b/tests/unit_tests/silvers/hunter_x_demo.sriv
index 36aff43..cc3e0aa 100644
--- a/tests/unit_tests/silvers/hunter_x_demo.sriv
+++ b/tests/unit_tests/silvers/hunter_x_demo.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/ik_anim_test.sriv b/tests/unit_tests/silvers/ik_anim_test.sriv
index 9a6f397..8d50ecc 100644
--- a/tests/unit_tests/silvers/ik_anim_test.sriv
+++ b/tests/unit_tests/silvers/ik_anim_test.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/interactive_scrolling.sriv b/tests/unit_tests/silvers/interactive_scrolling.sriv
index d4191da..efb2199 100644
--- a/tests/unit_tests/silvers/interactive_scrolling.sriv
+++ b/tests/unit_tests/silvers/interactive_scrolling.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/interpolation_zero_duration.sriv b/tests/unit_tests/silvers/interpolation_zero_duration.sriv
index b9c8481..06e766e 100644
--- a/tests/unit_tests/silvers/interpolation_zero_duration.sriv
+++ b/tests/unit_tests/silvers/interpolation_zero_duration.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/inventory_demo_test_v2.sriv b/tests/unit_tests/silvers/inventory_demo_test_v2.sriv
index d40874b..76c6caa 100644
--- a/tests/unit_tests/silvers/inventory_demo_test_v2.sriv
+++ b/tests/unit_tests/silvers/inventory_demo_test_v2.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/joystick_databound_keyframe_test.sriv b/tests/unit_tests/silvers/joystick_databound_keyframe_test.sriv
index dd82598..9f06611 100644
--- a/tests/unit_tests/silvers/joystick_databound_keyframe_test.sriv
+++ b/tests/unit_tests/silvers/joystick_databound_keyframe_test.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/juice.sriv b/tests/unit_tests/silvers/juice.sriv
index 12c9c57..df453da 100644
--- a/tests/unit_tests/silvers/juice.sriv
+++ b/tests/unit_tests/silvers/juice.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/layout_grid_stack_grid_with_layout_participants.sriv b/tests/unit_tests/silvers/layout_grid_stack_grid_with_layout_participants.sriv
index 321e073..1e5650e 100644
--- a/tests/unit_tests/silvers/layout_grid_stack_grid_with_layout_participants.sriv
+++ b/tests/unit_tests/silvers/layout_grid_stack_grid_with_layout_participants.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/layoutstest_8-planets-grid.sriv b/tests/unit_tests/silvers/layoutstest_8-planets-grid.sriv
index fa98a88..6e1f5ad 100644
--- a/tests/unit_tests/silvers/layoutstest_8-planets-grid.sriv
+++ b/tests/unit_tests/silvers/layoutstest_8-planets-grid.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/nested_artboard_quantize_and_speed.sriv b/tests/unit_tests/silvers/nested_artboard_quantize_and_speed.sriv
index 95a1f4e..7e27c87 100644
--- a/tests/unit_tests/silvers/nested_artboard_quantize_and_speed.sriv
+++ b/tests/unit_tests/silvers/nested_artboard_quantize_and_speed.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/pause_nested_artboard.sriv b/tests/unit_tests/silvers/pause_nested_artboard.sriv
index 48c6a4c..21cbffd 100644
--- a/tests/unit_tests/silvers/pause_nested_artboard.sriv
+++ b/tests/unit_tests/silvers/pause_nested_artboard.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/paused_nested_artboard_opacity.sriv b/tests/unit_tests/silvers/paused_nested_artboard_opacity.sriv
index 93a3c45..1797a1d 100644
--- a/tests/unit_tests/silvers/paused_nested_artboard_opacity.sriv
+++ b/tests/unit_tests/silvers/paused_nested_artboard_opacity.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/pointer_exit.sriv b/tests/unit_tests/silvers/pointer_exit.sriv
index ccf5a04..77d75ee 100644
--- a/tests/unit_tests/silvers/pointer_exit.sriv
+++ b/tests/unit_tests/silvers/pointer_exit.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/relative_data_bind_path.sriv b/tests/unit_tests/silvers/relative_data_bind_path.sriv
index 06ed23d..42e67d3 100644
--- a/tests/unit_tests/silvers/relative_data_bind_path.sriv
+++ b/tests/unit_tests/silvers/relative_data_bind_path.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/reset_phase_multi_main.sriv b/tests/unit_tests/silvers/reset_phase_multi_main.sriv
index 7869d6a..3a3d611 100644
--- a/tests/unit_tests/silvers/reset_phase_multi_main.sriv
+++ b/tests/unit_tests/silvers/reset_phase_multi_main.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/rewards_demo.sriv b/tests/unit_tests/silvers/rewards_demo.sriv
index 3711375..21bf796 100644
--- a/tests/unit_tests/silvers/rewards_demo.sriv
+++ b/tests/unit_tests/silvers/rewards_demo.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/saturation.sriv b/tests/unit_tests/silvers/saturation.sriv
index a2b3848..0a6e31b 100644
--- a/tests/unit_tests/silvers/saturation.sriv
+++ b/tests/unit_tests/silvers/saturation.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/script_artboards_opacity.sriv b/tests/unit_tests/silvers/script_artboards_opacity.sriv
index 2ae363d..b69ea40 100644
--- a/tests/unit_tests/silvers/script_artboards_opacity.sriv
+++ b/tests/unit_tests/silvers/script_artboards_opacity.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/script_artboards_origin.sriv b/tests/unit_tests/silvers/script_artboards_origin.sriv
index 2d5875f..ec4611f 100644
--- a/tests/unit_tests/silvers/script_artboards_origin.sriv
+++ b/tests/unit_tests/silvers/script_artboards_origin.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/script_path_effects.sriv b/tests/unit_tests/silvers/script_path_effects.sriv
index 696ea18..da4afdc 100644
--- a/tests/unit_tests/silvers/script_path_effects.sriv
+++ b/tests/unit_tests/silvers/script_path_effects.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/scripting_linear_animation.sriv b/tests/unit_tests/silvers/scripting_linear_animation.sriv
index d9aa0f8..f097743 100644
--- a/tests/unit_tests/silvers/scripting_linear_animation.sriv
+++ b/tests/unit_tests/silvers/scripting_linear_animation.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/scroll_threshold-horizontal-scroll.sriv b/tests/unit_tests/silvers/scroll_threshold-horizontal-scroll.sriv
index 9c13199..aead127 100644
--- a/tests/unit_tests/silvers/scroll_threshold-horizontal-scroll.sriv
+++ b/tests/unit_tests/silvers/scroll_threshold-horizontal-scroll.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/scroll_threshold-vertical-scroll.sriv b/tests/unit_tests/silvers/scroll_threshold-vertical-scroll.sriv
index 970367f..c297902 100644
--- a/tests/unit_tests/silvers/scroll_threshold-vertical-scroll.sriv
+++ b/tests/unit_tests/silvers/scroll_threshold-vertical-scroll.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/spotify_kids_app_icon.sriv b/tests/unit_tests/silvers/spotify_kids_app_icon.sriv
index 2c4976f..2694b5c 100644
--- a/tests/unit_tests/silvers/spotify_kids_app_icon.sriv
+++ b/tests/unit_tests/silvers/spotify_kids_app_icon.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/spotify_kids_demo.sriv b/tests/unit_tests/silvers/spotify_kids_demo.sriv
index 6f8139e..92a10ff 100644
--- a/tests/unit_tests/silvers/spotify_kids_demo.sriv
+++ b/tests/unit_tests/silvers/spotify_kids_demo.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/stateful_list_props_lifecycle.sriv b/tests/unit_tests/silvers/stateful_list_props_lifecycle.sriv
index 4b1378a..06efb20 100644
--- a/tests/unit_tests/silvers/stateful_list_props_lifecycle.sriv
+++ b/tests/unit_tests/silvers/stateful_list_props_lifecycle.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/stateful_multi_property.sriv b/tests/unit_tests/silvers/stateful_multi_property.sriv
index 516740e..b3186f0 100644
--- a/tests/unit_tests/silvers/stateful_multi_property.sriv
+++ b/tests/unit_tests/silvers/stateful_multi_property.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/stateful_nested.sriv b/tests/unit_tests/silvers/stateful_nested.sriv
index 999c042..9e63aca 100644
--- a/tests/unit_tests/silvers/stateful_nested.sriv
+++ b/tests/unit_tests/silvers/stateful_nested.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/target_event.sriv b/tests/unit_tests/silvers/target_event.sriv
index 05a24a6..da8f6b8 100644
--- a/tests/unit_tests/silvers/target_event.sriv
+++ b/tests/unit_tests/silvers/target_event.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/text_layout_7_3.sriv b/tests/unit_tests/silvers/text_layout_7_3.sriv
index 691a9ae..04e4cf5 100644
--- a/tests/unit_tests/silvers/text_layout_7_3.sriv
+++ b/tests/unit_tests/silvers/text_layout_7_3.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/text_layout_pre_7_3.sriv b/tests/unit_tests/silvers/text_layout_pre_7_3.sriv
index 5b4aeed..694a46d 100644
--- a/tests/unit_tests/silvers/text_layout_pre_7_3.sriv
+++ b/tests/unit_tests/silvers/text_layout_pre_7_3.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/time_based_interpolation.sriv b/tests/unit_tests/silvers/time_based_interpolation.sriv
index e60c9c7..033c2ac 100644
--- a/tests/unit_tests/silvers/time_based_interpolation.sriv
+++ b/tests/unit_tests/silvers/time_based_interpolation.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/transition_duration_bind_list.sriv b/tests/unit_tests/silvers/transition_duration_bind_list.sriv
index 67b79e8..40a2560 100644
--- a/tests/unit_tests/silvers/transition_duration_bind_list.sriv
+++ b/tests/unit_tests/silvers/transition_duration_bind_list.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/transition_duration_bind_nested.sriv b/tests/unit_tests/silvers/transition_duration_bind_nested.sriv
index 369dcb5..aa4a4bf 100644
--- a/tests/unit_tests/silvers/transition_duration_bind_nested.sriv
+++ b/tests/unit_tests/silvers/transition_duration_bind_nested.sriv
Binary files differ
diff --git a/tests/unit_tests/silvers/virtualized_artboard_databound_children.sriv b/tests/unit_tests/silvers/virtualized_artboard_databound_children.sriv
index f26cae9..93f86ed 100644
--- a/tests/unit_tests/silvers/virtualized_artboard_databound_children.sriv
+++ b/tests/unit_tests/silvers/virtualized_artboard_databound_children.sriv
Binary files differ