feat(deferred): one session per render context, multi target replay, and the context tier deleted (#13368) e9f24d0296
* feat(deferred): one session per render context with multi target replay, the shared context tier deleted, and script GPU work routed from the import factory

* fix(editor): paint the stage background while a new file's scripts compile, the vm swap skip was keeping the previous file's frame on the shared texture

* fix(web): detach the deferred session before a pooled renderer is released, deleting a bound renderer left the session replaying into dead GL state

* fix(editor): retry the shared texture clear until it lands or the new stage paints, opening from the file browser skipped it while the canvas was off screen

* fix(editor): hold the shared texture clear until the canvas is displayed with real size, a frame recorded before then replays with no screen to present to

* fix(editor): clear the shared texture when leaving for the file browser and let a transferred canvas clear while detached, the worker draws its bitmap regardless of the DOM

* fix(web): present every screen target on the web sink, a frame recorded before a repoint carried the prior texture's target and static content went blank when it was refused

* chore: move the investigation scripts and write ups to archive/deferred-investigation-scripts, the suite carries the conclusions as asserts

* test(gm): the deferred GMs check themselves against their immediate frame in process, each pair was two identical goldens standing in for one parity assert, 487 baselines gone

* chore: drop the working notes and dated benchmark reports from the tree, the archive branch keeps them

* docs: pull the deferred rendering write up out of the tree, it lives on the archive branch

* chore: pull the benchmark suite, its skill, driver, and bench only assets out of this landing, the archive branch carries them for the follow up

* ci: run the example integration tests by directory again, the target list script left with the benchmark suite

* ci: claim parity gm families so one worker runs each, and fetch the parity rivs over lfs in the unit test jobs

* test(gm): seed browserstack baselines for the canvas dag gms from a metal render, the device rebaseline flow can refresh them if a threshold trips

* refactor(runtime): drop the file level instance factory params, every dart call site passed null since the session became the import factory

* refactor(deferred): drop the unused dart gpu census mirror, the investigation tooling that read it moved to the archive branch

* refactor(native): drop exports with zero callers, riveShutdownGPUScripting was the only path freeing the context session so wiring real process exit teardown is a conscious follow-up

* refactor(deferred): fold the dispose helper into its only caller and stop exporting it, nothing outside the package disposed a session

* refactor(cmd): drop accessors nothing reads, the live counter's asserting recorder destructor is long gone

* refactor(cmd): guard the 2d producer with the recording thread check, make id reuse unconditional, and drop the inert ore replay marker opcode and the canvas hook seam nothing plugs into

* fix(web): an explicit released flag for isDisposed and an eager context for immediate consumers, a lazy texture read as disposed from birth and the pool bound stopped meaning live contexts

* fix(cmd): read past the canvas id without naming it, the hook deletion left the variable unused and ci builds with werror

* fix(tests): a lost context makes isReady false again, the parity rivs ship as plain assets so devices get real bytes, and the corpus walks use filesystem so msvc builds

* fix(cmd): the 2d recording thread bind sat unreachable after a lambda return, move it into the constructor body

* fix(deferred): probe the whole deferred abi before enabling it, make a failed worker renderer terminal instead of forever pending, and assert when a neighbor's frame reaches a native sink

* chore(deferred): name the slot store as the worker bind publication point, pin factory immutability on the widget, and give the editor clear retries one generation

* chore: move the factory immutability note above the condition it explains

* test(gm): allow the golden diff tolerance on atomic backends in the parity compare, their raster order differs run to run and exactness failed frames no two of theirs ever matched

* test(goldens): rebaseline Hero_v2 on vulkan atomic, our vulkan prepass fixes move 72 pixels by up to 14 and the shift reproduced twice, candidate eyeballed at full size

* refactor(canvas): replace the deferBacking bool and sticky recording flag with makeDeferredRenderCanvas, only GL overrides it

* chore(ps5): restore the agc canvas overloads to master, the base signature no longer forces the reshape

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index 6143029..5ee99bb 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-e17fbf4aa8f011ec64e94b982b4e7aa7e9874010
+e9f24d0296d8f2513b0ac1287b9c5fb5f1314f7f
diff --git a/dev/defs/assets/script_asset.json b/dev/defs/assets/script_asset.json
index 419ff1a..cb95eef 100644
--- a/dev/defs/assets/script_asset.json
+++ b/dev/defs/assets/script_asset.json
@@ -56,7 +56,7 @@
         "int": 1022,
         "string": "serializedimplementedmethods"
       },
-      "description": "Bitfield of the optional script methods (init/draw/drawCanvas/advance/etc.) the editor detected by executing the generator. Bit layout matches OptionalScriptedMethods (bits 0-20). The runtime reads these directly instead of detecting at load. Defaults to all bits set ((1<<21)-1 = 2097151) so files exported before this property existed behave as 'implements everything' and rely on graceful dispatch (each callback no-ops when the method isn't actually present).",
+      "description": "Bitfield of the optional script methods (init/draw/advance/etc.) the editor detected by executing the generator. Bit layout matches OptionalScriptedMethods (bits 0-20). The runtime reads these directly instead of detecting at load. Defaults to all bits set ((1<<21)-1 = 2097151) so files exported before this property existed behave as 'implements everything' and rely on graceful dispatch (each callback no-ops when the method isn't actually present).",
       "coop": false,
       "exportsToRuntimeConditionally": true,
       "journal": false
diff --git a/include/rive/artboard.hpp b/include/rive/artboard.hpp
index 9873159..dbe1d4c 100644
--- a/include/rive/artboard.hpp
+++ b/include/rive/artboard.hpp
@@ -337,20 +337,10 @@
     Drawable* firstDrawable() { return m_FirstDrawable; };
     void addScriptedObject(ScriptedObject* object);
 
-    void drawCanvases();
-    void internalDrawCanvases();
-
     /// Poll async work (image decodes, etc.) so promises resolve before
     /// script callbacks run. Called at the top of advance().
     void pollAsyncWork();
 
-#ifdef WITH_RIVE_SCRIPTING
-    /// Returns the lua_State* (as void*) for the first drawCanvas scripted
-    /// object in this artboard or any nested artboard, recursively. Returns
-    /// nullptr if no drawCanvas scripts exist. Used by the Dart FFI layer to
-    /// open a GPU frame before calling drawCanvases().
-    void* findDrawCanvasLuauState() const;
-#endif
     void drawInternal(Renderer* renderer);
     void draw(Renderer* renderer) override;
     void addToRenderPath(RenderPath* path, const Mat2D& transform);
@@ -544,13 +534,16 @@
     // provided.
     int defaultStateMachineIndex() const;
 
-    /// Make an instance of this artboard.
-    template <typename T = ArtboardInstance> std::unique_ptr<T> instance() const
+    /// Make an instance of this artboard. A non null factory reroutes the
+    /// instance's render resource creation (a deferred session facade);
+    /// nested instances inherit it.
+    template <typename T = ArtboardInstance>
+    std::unique_ptr<T> instance(Factory* factory = nullptr) const
     {
         std::unique_ptr<T> artboardClone(new T);
         artboardClone->copy(*this);
 
-        artboardClone->m_Factory = m_Factory;
+        artboardClone->m_Factory = factory != nullptr ? factory : m_Factory;
         artboardClone->m_FrameOrigin = m_FrameOrigin;
         artboardClone->m_DataContext = m_DataContext;
         artboardClone->m_IsInstance = true;
@@ -592,6 +585,14 @@
             artboardClone->m_StateMachines.push_back(stateMachine);
         }
 
+        if (factory != nullptr && factory != m_Factory)
+        {
+            // Nested clones instanced off the file level source during the
+            // clone loop; redo them on the override factory before
+            // initialize wires animations to them.
+            artboardClone->reinstanceNestedArtboards(factory);
+        }
+
         if (artboardClone->initialize() != StatusCode::Ok)
         {
             artboardClone = nullptr;
@@ -601,6 +602,8 @@
         return artboardClone;
     }
 
+    void reinstanceNestedArtboards(Factory* factory);
+
     /// Returns true if the artboard is an instance of another
     bool isInstance() const { return m_IsInstance; }
 
diff --git a/include/rive/assets/script_asset.hpp b/include/rive/assets/script_asset.hpp
index 5ec6ddb..a7f0493 100644
--- a/include/rive/assets/script_asset.hpp
+++ b/include/rive/assets/script_asset.hpp
@@ -87,7 +87,8 @@
     static const int m_resizesBit = 1 << 12;
     static const int m_listenerPerforms = 1 << 13;
     static const int m_listenerPerformsAction = 1 << 14;
-    static const int m_drawsCanvasBit = 1 << 15;
+    // Bit 15 was drawCanvas; the callback is gone but the wire bit stays
+    // reserved so older exports keep their layout.
     static const int m_wantsKeyboardInputBit = 1 << 16;
     static const int m_wantsTextInputBit = 1 << 17;
     static const int m_wantsGamepadConnect = 1 << 18;
@@ -168,10 +169,6 @@
     {
         return (m_implementedMethods & m_dataReverseConvertsBit) != 0;
     }
-    bool drawsCanvas()
-    {
-        return (m_implementedMethods & m_drawsCanvasBit) != 0;
-    }
     bool wantsKeyboardInput()
     {
         return (m_implementedMethods & m_wantsKeyboardInputBit) != 0;
diff --git a/include/rive/factory.hpp b/include/rive/factory.hpp
index dbee874..1c9fc72 100644
--- a/include/rive/factory.hpp
+++ b/include/rive/factory.hpp
@@ -23,6 +23,10 @@
 {
 class Context;
 }
+namespace cmd
+{
+class DeferredCanvasHost;
+}
 
 class Factory
 {
@@ -69,6 +73,19 @@
     // shifting existing vtable slots.
     virtual ore::Context* ore() { return nullptr; }
 
+    // The GPU render context an import through this factory should give its
+    // scripts, as a Factory so this header stays free of gpu types. A render
+    // context answers with itself; a recording session answers with the one it
+    // records for, which on web is null until a render texture attaches, so
+    // callers that deferred an allocation ask again rather than caching the
+    // null they saw at import. Null means the importer cannot route GPU
+    // scripting.
+    virtual Factory* renderContext() { return nullptr; }
+
+    // Set when script canvas work must record rather than issue. Null means
+    // scripts draw straight to the driver.
+    virtual cmd::DeferredCanvasHost* deferredCanvasHost() { return nullptr; }
+
     rcp<Font> decodeFont(Span<const uint8_t>);
 
     rcp<AudioSource> decodeAudio(Span<const uint8_t>);
diff --git a/include/rive/file.hpp b/include/rive/file.hpp
index c662e79..1325033 100644
--- a/include/rive/file.hpp
+++ b/include/rive/file.hpp
@@ -40,6 +40,7 @@
 class ViewModelRuntime;
 class BindableArtboard;
 class ScriptingVM;
+class ScriptingContext;
 class ScriptedInterpolator;
 
 ///
@@ -129,7 +130,6 @@
 
     Span<const rcp<FileAsset>> assets() const;
 
-    // Instances
     std::unique_ptr<ArtboardInstance> artboardDefault() const;
     std::unique_ptr<ArtboardInstance> artboardAt(size_t index) const;
     std::unique_ptr<ArtboardInstance> artboardNamed(std::string name) const;
@@ -315,6 +315,7 @@
     void makeScriptingVM();
     void cleanupScriptingVM();
     void registerScripts();
+    void routeScriptingToImportFactory(ScriptingContext* context);
 #endif
 
     rcp<ViewModelInstance> copyViewModelInstance(
diff --git a/include/rive/lua/rive_lua_libs.hpp b/include/rive/lua/rive_lua_libs.hpp
index cb0fba7..faa7eb9 100644
--- a/include/rive/lua/rive_lua_libs.hpp
+++ b/include/rive/lua/rive_lua_libs.hpp
@@ -72,6 +72,10 @@
 class ScriptedObject;
 class StateMachineInstance;
 class TransformComponent;
+namespace cmd
+{
+class DeferredCanvasHost;
+}
 enum class LuaAtoms : int16_t
 {
     // Vector
@@ -327,7 +331,6 @@
     resize,
     canvas,
     gpuCanvas,
-    drawCanvas,
     features,
     shader,
     format,
@@ -813,6 +816,12 @@
     lua_State* m_L = nullptr;
     int m_imageRef = LUA_NOREF;
     gpu::RenderContext* renderCtx = nullptr; // needed for resize()
+    // Size a resize() asked for while no device existed. Web attaches one per
+    // render texture after layout has already run, and a generator resizes
+    // once, so the request is held here and honoured on the first access after
+    // a device appears. Zero once satisfied.
+    uint32_t pendingWidth = 0;
+    uint32_t pendingHeight = 0;
 };
 
 #endif // RIVE_ORE
@@ -834,12 +843,22 @@
     lua_State* m_L = nullptr;
     int m_imageRef = LUA_NOREF;
     gpu::RenderContext* renderCtx = nullptr;
+    // See ScriptedGPUCanvas::pendingWidth.
+    uint32_t pendingWidth = 0;
+    uint32_t pendingHeight = 0;
     CanvasState m_state = CanvasState::Idle;
     // Allocated on beginFrame(), deleted on endFrame(). Wraps renderCtx.
+    // Null in deferred mode, content records into the stream instead.
     RiveRenderer* m_riveRenderer = nullptr;
+    // Set on beginFrame() when a deferred host is recording, endFrame()
+    // routes through it instead of the real flush. Null means immediate.
+    cmd::DeferredCanvasHost* m_deferredHost = nullptr;
     // Lua registry ref to the ScriptedRenderer pushed by beginFrame(),
     // kept alive until endFrame() so the Lua renderer stays valid.
     int m_rendererRef = LUA_NOREF;
+    // Registry ref while the frame is open so post-error cleanup can close
+    // it and GC cannot collect it mid-frame.
+    int m_openFrameRef = LUA_NOREF;
 };
 #endif // RIVE_CANVAS
 
@@ -1523,6 +1542,9 @@
 // Finishes any ORE render pass left open at script return and reports it
 // as a Lua error. Defined in src/lua/renderer/lua_gpu.cpp.
 void rive_lua_closeOrphanRenderPass(lua_State* state);
+// Ends any Canvas frame an errored script left open, which would otherwise
+// corrupt the deferred stream. Defined in src/lua/renderer/lua_gpu.cpp.
+void rive_lua_closeOrphanCanvasFrames(lua_State* state);
 #endif
 
 class ScriptingContext
@@ -1598,11 +1620,19 @@
         std::string m_bare;
     };
 
-    // Ore GPU context for this VM, derived from the render factory. Null when
-    // there is no render context, or it is not GPU-backed. Returned as void* so
-    // callers that include ore headers cast to ore::Context*.
+    // Ore GPU context for this VM, void* so callers cast to ore::Context*.
+    // A deferred host can override it via setOreContext to record instead.
     void* oreContext() const
     {
+        if (m_oreContextOverride != nullptr)
+            return m_oreContextOverride;
+        // A recording construction factory owns the context this VM's GPU work
+        // has to record into, and it has one before any device exists.
+        if (m_factory != nullptr)
+        {
+            if (auto* recording = m_factory->ore())
+                return recording;
+        }
         return m_renderContext ? m_renderContext->ore() : nullptr;
     }
 
@@ -1611,7 +1641,37 @@
     // construction factory(). A RenderContext is a Factory, so callers needing
     // gpu APIs cast down to gpu::RenderContext*.
     void setRenderContext(Factory* ctx) { m_renderContext = ctx; }
-    Factory* renderContext() const { return m_renderContext; }
+    Factory* renderContext() const
+    {
+        if (m_renderContext != nullptr)
+            return m_renderContext;
+        // Nothing handed this VM a device. A recording factory may still have
+        // been given one after import, so ask instead of reporting the null we
+        // saw while the scripts were running.
+        return m_factory != nullptr ? m_factory->renderContext() : nullptr;
+    }
+
+    // True when renderContext() resolved through the recording factory rather
+    // than a device handed to this VM. That device belongs to whoever attached
+    // it, so canvas backings must be deferred to it instead of allocated here.
+    bool renderContextIsLateBound() const { return m_renderContext == nullptr; }
+
+    // Point scripts at a DeferredOreContext so their GPU work records
+    // instead of touching the driver. Null restores the default.
+    void setOreContext(void* ctx) { m_oreContextOverride = ctx; }
+    // Raw override, for transferring deferred routing across a context swap.
+    void* oreContextOverride() const { return m_oreContextOverride; }
+
+    // When set, Canvas:beginFrame records into the deferred stream instead
+    // of issuing to the real RenderContext. Null means immediate.
+    void setDeferredCanvasHost(cmd::DeferredCanvasHost* host)
+    {
+        m_deferredCanvasHost = host;
+    }
+    cmd::DeferredCanvasHost* deferredCanvasHost() const
+    {
+        return m_deferredCanvasHost;
+    }
 
     // WorkPool for async operations (image decode, etc.).
     // Lazily created on first access. Shared across all contexts via a
@@ -1633,10 +1693,24 @@
     void setOreFrameOpen(bool open) { m_oreFrameOpen = open; }
     bool oreFrameOpen() const { return m_oreFrameOpen; }
 
-    // True while Artboard::drawCanvases() is actively walking scripted
-    // objects to invoke their drawCanvas() Lua callbacks.
-    void setCanvasDrawingPhase(bool value) { m_canvasDrawingPhase = value; }
-    bool canvasDrawingPhase() const { return m_canvasDrawingPhase; }
+    // Open canvas frames as registry refs so the post-pcall cleanup can
+    // close frames an errored script abandoned.
+    void registerOpenCanvasFrame(int ref) { m_openCanvasFrames.push_back(ref); }
+    void unregisterOpenCanvasFrame(int ref)
+    {
+        for (size_t i = 0; i < m_openCanvasFrames.size(); i++)
+        {
+            if (m_openCanvasFrames[i] == ref)
+            {
+                m_openCanvasFrames.erase(m_openCanvasFrames.begin() + i);
+                return;
+            }
+        }
+    }
+    std::vector<int> takeOpenCanvasFrames()
+    {
+        return std::move(m_openCanvasFrames);
+    }
 
     // When set, context:gpuCanvas() always returns a deferred (texture-less)
     // canvas regardless of requested size, never calling makeRenderCanvas.
@@ -1666,10 +1740,12 @@
 
 private:
     Factory* m_renderContext = nullptr;
+    void* m_oreContextOverride = nullptr; // deferred host's DeferredOreContext
+    cmd::DeferredCanvasHost* m_deferredCanvasHost = nullptr;
     uint64_t m_ownerId = 0;
     bool m_oreFrameOpen = false;
-    bool m_canvasDrawingPhase = false;
     bool m_gpuCanvasDeferOnly = false;
+    std::vector<int> m_openCanvasFrames;
     intptr_t m_prevGLContext = 0;
 #ifdef __EMSCRIPTEN__
     int m_glHandle = 0;
@@ -1735,6 +1811,16 @@
 #endif
 };
 
+#ifdef RIVE_CANVAS
+// Allocates a script canvas backing, deferring when a session is recording
+// or the device was late bound, since either way the replay worker owns the
+// texture.
+rcp<gpu::RenderCanvas> allocScriptRenderCanvas(gpu::RenderContext* rc,
+                                               ScriptingContext* ctx,
+                                               uint32_t width,
+                                               uint32_t height);
+#endif
+
 class ScopedScriptedObjectContext
 {
 public:
@@ -1763,32 +1849,6 @@
     ScriptedObject* m_previous;
 };
 
-class ScopedCanvasDrawingPhase
-{
-public:
-    ScopedCanvasDrawingPhase(ScriptingContext* context) :
-        m_context(context),
-        m_previous(context == nullptr ? false : context->canvasDrawingPhase())
-    {
-        if (m_context != nullptr)
-        {
-            m_context->setCanvasDrawingPhase(true);
-        }
-    }
-
-    ~ScopedCanvasDrawingPhase()
-    {
-        if (m_context != nullptr)
-        {
-            m_context->setCanvasDrawingPhase(m_previous);
-        }
-    }
-
-private:
-    ScriptingContext* m_context;
-    bool m_previous;
-};
-
 class ScriptedDataValue
 {
 public:
diff --git a/include/rive/math/raw_path.hpp b/include/rive/math/raw_path.hpp
index d567f08..a93b4f0 100644
--- a/include/rive/math/raw_path.hpp
+++ b/include/rive/math/raw_path.hpp
@@ -25,6 +25,15 @@
 class RawPath
 {
 public:
+    RawPath() = default;
+
+    // Bulk copy for deserialization, arrays trusted self-consistent. Contour
+    // bookkeeping stays default since the path is consumed, not built.
+    RawPath(Span<const PathVerb> verbs, Span<const Vec2D> points) :
+        m_Points(points.data(), points.data() + points.size()),
+        m_Verbs(verbs.data(), verbs.data() + verbs.size())
+    {}
+
     bool operator==(const RawPath& o) const;
     bool operator!=(const RawPath& o) const { return !(*this == o); }
 
diff --git a/include/rive/scripted/scripted_drawable.hpp b/include/rive/scripted/scripted_drawable.hpp
index 0076862..7b5584b 100644
--- a/include/rive/scripted/scripted_drawable.hpp
+++ b/include/rive/scripted/scripted_drawable.hpp
@@ -23,6 +23,7 @@
 public:
 #ifdef WITH_RIVE_SCRIPTING
     void didHydrateScriptInputs() override;
+    void didReinit() override;
 #endif
     void draw(Renderer* renderer) override;
     void update(ComponentDirt value) override;
@@ -73,6 +74,9 @@
 
 private:
     bool m_isAdvanceActive = true;
+    // One zero step after a reinit so advance driven content, like gpu
+    // canvas fills, re-records while paused.
+    bool m_forceAdvance = false;
 };
 
 class HitScriptedDrawable : public HitComponent
diff --git a/include/rive/scripted/scripted_object.hpp b/include/rive/scripted/scripted_object.hpp
index db4769a..5ecbd09 100644
--- a/include/rive/scripted/scripted_object.hpp
+++ b/include/rive/scripted/scripted_object.hpp
@@ -66,7 +66,6 @@
     void setViewModelInput(std::string name, ViewModelInstanceValue* value);
     void trigger(std::string name);
     bool scriptAdvance(float elapsedSeconds);
-    void scriptDrawCanvas();
     void scriptUpdate();
     void reinit();
 #ifdef WITH_RIVE_SCRIPTING
@@ -125,6 +124,9 @@
 #ifdef WITH_RIVE_SCRIPTING
     /// Called after hydrateScriptInputs() succeeds;
     virtual void didHydrateScriptInputs() {}
+    // A reinit replaced script state, like a VM swap on editor pause; hosts
+    // re-record content the old state produced.
+    virtual void didReinit() {}
 #endif
 };
 } // namespace rive
diff --git a/include/utils/serialize_ops.hpp b/include/utils/serialize_ops.hpp
new file mode 100644
index 0000000..c73b7cb
--- /dev/null
+++ b/include/utils/serialize_ops.hpp
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#ifndef _RIVE_SERIALIZE_OPS_HPP_
+#define _RIVE_SERIALIZE_OPS_HPP_
+
+#include "rive/core/binary_reader.hpp"
+#include "rive/core/binary_writer.hpp"
+#include "rive/math/raw_path.hpp"
+#include <vector>
+
+namespace rive
+{
+// Wire opcodes shared by SerializingFactory and replaySerializedCommands so
+// the two ends of the .sriv format cannot drift apart.
+enum class SerializeOp : uint32_t
+{
+    makeRenderBuffer = 0,
+    makeLinearGradient = 1,
+    makeRadialGradient = 2,
+    makeRenderPath = 3,
+    makeRenderPaint = 5,
+    decodeImage = 6,
+    save = 7,
+    restore = 8,
+    transform = 9,
+    drawPath = 10,
+    clipPath = 11,
+    drawImage = 12,
+    drawImageMesh = 13,
+
+    // RenderBuffer
+    setVertexBufferData = 14,
+    setIndexBufferData = 15,
+
+    // RenderPath
+    addRawPath = 16,
+    rewind = 17,
+    fillRule = 18,
+
+    // RenderPaint
+    style = 20,
+    color = 21,
+    thickness = 22,
+    join = 23,
+    cap = 24,
+    feather = 25,
+    blendMode = 26,
+    shader = 27,
+
+    frame = 28,
+    frameSize = 29,
+    modulateOpacity = 30,
+};
+
+inline void serializeRawPath(BinaryWriter* writer, const RawPath& path)
+{
+    auto verbs = path.verbs();
+    auto points = path.points();
+    writer->writeVarUint((uint64_t)verbs.size());
+    for (auto verb : verbs)
+    {
+        writer->writeVarUint((uint64_t)verb);
+    }
+    writer->writeVarUint((uint64_t)points.size());
+    for (auto point : points)
+    {
+        writer->writeFloat(point.x);
+        writer->writeFloat(point.y);
+    }
+}
+
+inline RawPath deserializeRawPath(BinaryReader& reader)
+{
+    RawPath path;
+    size_t verbCount = static_cast<size_t>(reader.readVarUint64());
+    std::vector<PathVerb> verbs(verbCount);
+    for (size_t i = 0; i < verbCount; ++i)
+        verbs[i] = static_cast<PathVerb>(reader.readVarUint64());
+    size_t pointCount = static_cast<size_t>(reader.readVarUint64());
+    std::vector<Vec2D> pts(pointCount);
+    for (size_t i = 0; i < pointCount; ++i)
+    {
+        pts[i].x = reader.readFloat32();
+        pts[i].y = reader.readFloat32();
+    }
+    size_t p = 0;
+    // A truncated stream can promise more points than it delivers.
+    auto have = [&](size_t n) { return p + n <= pts.size(); };
+    for (PathVerb v : verbs)
+    {
+        switch (v)
+        {
+            case PathVerb::move:
+                if (!have(1))
+                    return path;
+                path.move(pts[p++]);
+                break;
+            case PathVerb::line:
+                if (!have(1))
+                    return path;
+                path.line(pts[p++]);
+                break;
+            case PathVerb::quad:
+                if (!have(2))
+                    return path;
+                path.quad(pts[p], pts[p + 1]);
+                p += 2;
+                break;
+            case PathVerb::cubic:
+                if (!have(3))
+                    return path;
+                path.cubic(pts[p], pts[p + 1], pts[p + 2]);
+                p += 3;
+                break;
+            case PathVerb::close:
+                path.close();
+                break;
+        }
+    }
+    return path;
+}
+} // namespace rive
+
+#endif
diff --git a/include/utils/serialized_replay.hpp b/include/utils/serialized_replay.hpp
new file mode 100644
index 0000000..9230b2f
--- /dev/null
+++ b/include/utils/serialized_replay.hpp
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#ifndef _RIVE_SERIALIZED_REPLAY_HPP_
+#define _RIVE_SERIALIZED_REPLAY_HPP_
+
+#include "rive/factory.hpp"
+#include "rive/renderer.hpp"
+#include "rive/span.hpp"
+#include <cstdint>
+#include <functional>
+
+// Replays a SerializingFactory SRIV stream against a real Factory and
+// Renderer. The stream records at the Factory and Renderer abstraction level
+// keyed by object id, so it is renderer implementation agnostic. Frame marker
+// ops invoke hooks so a host can drive begin and flush around each frame.
+namespace rive
+{
+
+struct SerializedReplayHooks
+{
+    std::function<void()> onFrame = nullptr;
+    std::function<void(uint32_t width, uint32_t height)> onFrameSize = nullptr;
+};
+
+// Returns false on a bad header, unknown opcode, or truncated stream. The
+// partial replay up to that point still happened.
+bool replaySerializedCommands(Span<const uint8_t> stream,
+                              Factory* factory,
+                              Renderer* renderer,
+                              const SerializedReplayHooks& hooks = {});
+
+} // namespace rive
+
+#endif
diff --git a/include/utils/serializing_factory.hpp b/include/utils/serializing_factory.hpp
index e180427..9516200 100644
--- a/include/utils/serializing_factory.hpp
+++ b/include/utils/serializing_factory.hpp
@@ -54,6 +54,12 @@
     void save(const char* filename);
     bool matches(const char* filename);
 
+    // Recorded SRIV stream for replay via serialized_replay.hpp.
+    Span<const uint8_t> bytes() const
+    {
+        return Span<const uint8_t>(m_buffer.data(), m_buffer.size());
+    }
+
 private:
     void saveTarnished(const char* filename);
 
diff --git a/renderer/include/rive/renderer/cmd/canvas_schedule.hpp b/renderer/include/rive/renderer/cmd/canvas_schedule.hpp
new file mode 100644
index 0000000..cf56ac3
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/canvas_schedule.hpp
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/deferred_session.hpp"
+
+#include <cstring>
+#include <vector>
+
+// Orders canvas replay so samplers follow the canvases they sample,
+// regardless of record order.
+namespace rive::cmd
+{
+
+struct CanvasSchedule
+{
+    // Canvas ids in replay group order. Record order when no edge disagrees.
+    std::vector<uint64_t> order;
+    // A dependency cycle (or self sample) was demoted to previous frame
+    // sampling; the demoted edge keeps record order.
+    bool hadCycle = false;
+    // A read landed between two writes of the same canvas; grouped-ranges
+    // replay cannot honor the middle state, so the frame keeps record order.
+    bool multiWriteFallback = false;
+};
+
+// Reads the image handle that leads every draw POD which can sample a canvas.
+inline RenderHandle drawnImageHandle(const uint8_t* pod)
+{
+    RenderHandle h;
+    memcpy(&h, pod, sizeof(h));
+    return h;
+}
+
+inline CanvasSchedule scheduleCanvases(
+    Span<const uint8_t> commands,
+    const std::vector<DeferredSegment>& segments)
+{
+    CanvasSchedule result;
+
+    // Written canvases, grouped; node index doubles as record-order rank.
+    struct Node
+    {
+        uint64_t canvasId;
+        uint32_t firstBegin; // earliest range start, the record-order key
+        uint32_t lastBegin;  // latest range start, for the sandwich test
+    };
+    std::vector<Node> nodes;
+    auto nodeFor = [&](uint64_t id) -> int {
+        for (size_t i = 0; i < nodes.size(); i++)
+        {
+            if (nodes[i].canvasId == id)
+            {
+                return static_cast<int>(i);
+            }
+        }
+        return -1;
+    };
+    for (const DeferredSegment& s : segments)
+    {
+        if (s.target != DeferredSegment::Target::canvas)
+        {
+            continue;
+        }
+        int n = nodeFor(s.targetId);
+        if (n < 0)
+        {
+            nodes.push_back({s.targetId, s.begin, s.begin});
+        }
+        else
+        {
+            nodes[n].lastBegin = s.begin;
+        }
+    }
+    if (nodes.empty())
+    {
+        return result;
+    }
+
+    // reader depends on written canvas: edges[reader] holds node indices.
+    std::vector<std::vector<int>> deps(nodes.size());
+    for (const DeferredSegment& s : segments)
+    {
+        if (s.target != DeferredSegment::Target::canvas)
+        {
+            continue;
+        }
+        int reader = nodeFor(s.targetId);
+        uint32_t pos = s.begin;
+        while (pos < s.end && pos < commands.size())
+        {
+            RenderCmd c = static_cast<RenderCmd>(commands[pos]);
+            if (c > RenderCmd::lastRenderCmd)
+            {
+                break; // corrupt range; the decoder will warn at replay
+            }
+            uint32_t payload = static_cast<uint32_t>(payloadSizeOf(c));
+            if (c == RenderCmd::drawImage || c == RenderCmd::drawImageMesh)
+            {
+                RenderHandle h = drawnImageHandle(commands.data() + pos + 1);
+                if (h != kInvalidRenderHandle && (h & kCanvasHandleFlag))
+                {
+                    int sampled = nodeFor(h & kCanvasHandleMask);
+                    if (sampled == reader && sampled >= 0)
+                    {
+                        result.hadCycle = true; // self sample: previous frame
+                    }
+                    else if (sampled >= 0)
+                    {
+                        // A read between two writes of the sampled canvas
+                        // has no honorable grouped schedule.
+                        if (nodes[sampled].firstBegin < pos &&
+                            nodes[sampled].lastBegin > pos)
+                        {
+                            result.multiWriteFallback = true;
+                        }
+                        deps[reader].push_back(sampled);
+                    }
+                }
+            }
+            pos += 1 + payload;
+        }
+    }
+
+    if (result.multiWriteFallback)
+    {
+        for (const Node& n : nodes)
+        {
+            result.order.push_back(n.canvasId);
+        }
+        return result;
+    }
+
+    // Kahn with record-order preference; a stuck round emits the earliest
+    // recorded remaining node, demoting its unsatisfied edges (cycle case).
+    std::vector<bool> done(nodes.size(), false);
+    while (result.order.size() < nodes.size())
+    {
+        int pick = -1;
+        for (size_t i = 0; i < nodes.size(); i++)
+        {
+            if (done[i])
+            {
+                continue;
+            }
+            bool ready = true;
+            for (int d : deps[i])
+            {
+                if (!done[d])
+                {
+                    ready = false;
+                    break;
+                }
+            }
+            if (ready)
+            {
+                pick = static_cast<int>(i);
+                break;
+            }
+        }
+        if (pick < 0)
+        {
+            result.hadCycle = true;
+            for (size_t i = 0; i < nodes.size(); i++)
+            {
+                if (!done[i])
+                {
+                    pick = static_cast<int>(i);
+                    break;
+                }
+            }
+        }
+        done[pick] = true;
+        result.order.push_back(nodes[pick].canvasId);
+    }
+    return result;
+}
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/command_stream.hpp b/renderer/include/rive/renderer/cmd/command_stream.hpp
new file mode 100644
index 0000000..ed24361
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/command_stream.hpp
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/span.hpp"
+#include <cassert>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <type_traits>
+#include <vector>
+
+// Wire primitives shared by the 2D and Ore command streams: a flat pointer
+// free byte stream plus a blob arena, and its bounds checked reader. The
+// vocabularies differ per stream; the byte layout rules live only here.
+namespace rive::cmd
+{
+
+// Per site stderr throttle so a per frame failure cannot flood the log.
+#define RIVE_WARN_THROTTLED(...)                                               \
+    do                                                                         \
+    {                                                                          \
+        static int riveWarnCount = 0;                                          \
+        if ((riveWarnCount++ % 120) == 0)                                      \
+        {                                                                      \
+            fprintf(stderr, __VA_ARGS__);                                      \
+        }                                                                      \
+    } while (0)
+
+// A stream is written from its start and read from its start: every stream is
+// reset each frame, so a blob offset is just an index into the arena.
+class CommandByteStream
+{
+public:
+    // Copy bytes into the blob arena and return the offset for the command
+    // POD. Null data with a nonzero size would record unappended bytes, so it
+    // asserts. Offsets are 64 bit because the wire PODs carrying them are.
+    uint64_t appendBlob(const void* data, uint32_t size)
+    {
+        assert(data != nullptr || size == 0);
+        // Blobs start 8 aligned so typed reads of the contents are aligned.
+        m_blobs.resize((m_blobs.size() + 7) & ~size_t(7), 0);
+        uint64_t offset = m_blobs.size();
+        if (size != 0 && data != nullptr)
+        {
+            appendBytes(m_blobs, data, size);
+        }
+        return offset;
+    }
+
+    bool empty() const { return m_commands.empty(); }
+
+    Span<const uint8_t> commandBytes() const
+    {
+        return Span<const uint8_t>(m_commands.data(), m_commands.size());
+    }
+    Span<const uint8_t> blobBytes() const
+    {
+        return Span<const uint8_t>(m_blobs.data(), m_blobs.size());
+    }
+
+protected:
+    // Appends are the hottest thing recording does, and vector::insert drags
+    // its general mid-range machinery through every one of them.
+    static void appendBytes(std::vector<uint8_t>& dst,
+                            const void* data,
+                            size_t size)
+    {
+        size_t end = dst.size();
+        dst.resize(end + size);
+        memcpy(dst.data() + end, data, size);
+    }
+
+    void writeRaw(const void* data, size_t size)
+    {
+        appendBytes(m_commands, data, size);
+    }
+
+    void clearBytes() // keeps capacity
+    {
+        m_commands.clear();
+        m_blobs.clear();
+    }
+
+    std::vector<uint8_t> m_commands;
+    std::vector<uint8_t> m_blobs;
+};
+
+// Sequential bounds checked reader: an overrunning read latches and ends the
+// walk, an out of range blobAt returns an empty span.
+template <typename Opcode> class CommandReader
+{
+public:
+    CommandReader(Span<const uint8_t> commands, Span<const uint8_t> blobs) :
+        m_commands(commands), m_blobs(blobs)
+    {}
+
+    bool next(Opcode& outType)
+    {
+        size_t remaining = m_commands.size() - m_pos;
+        if (m_overrun || remaining < sizeof(Opcode))
+        {
+            // Leftover bytes too short for an opcode are a truncated stream,
+            // not a clean end.
+            if (remaining != 0)
+            {
+                m_overrun = true;
+            }
+            return false;
+        }
+        std::memcpy(&outType, m_commands.data() + m_pos, sizeof(Opcode));
+        m_pos += sizeof(Opcode);
+        return true;
+    }
+
+    template <typename POD> POD read()
+    {
+        static_assert(std::is_trivially_copyable<POD>::value);
+        POD pod{};
+        if (m_commands.size() - m_pos < sizeof(POD))
+        {
+            m_overrun = true;
+            return pod;
+        }
+        std::memcpy(&pod, m_commands.data() + m_pos, sizeof(POD));
+        m_pos += sizeof(POD);
+        return pod;
+    }
+
+    // Advance past a payload without reading it (filtered walks).
+    void skip(size_t bytes)
+    {
+        if (m_commands.size() - m_pos < bytes)
+        {
+            m_overrun = true;
+            return;
+        }
+        m_pos += bytes;
+    }
+
+    Span<const uint8_t> blobAt(uint64_t offset, uint32_t size) const
+    {
+        // Compare in 64 bit before any narrowing, size_t is 32 bit on wasm.
+        if (offset + size > static_cast<uint64_t>(m_blobs.size()))
+        {
+            return Span<const uint8_t>(nullptr, 0);
+        }
+        return Span<const uint8_t>(m_blobs.data() + static_cast<size_t>(offset),
+                                   size);
+    }
+
+    size_t position() const { return m_pos; }
+    bool overrun() const { return m_overrun; }
+
+private:
+    Span<const uint8_t> m_commands;
+    Span<const uint8_t> m_blobs;
+    size_t m_pos = 0;
+    bool m_overrun = false;
+};
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/deferred_canvas_host.hpp b/renderer/include/rive/renderer/cmd/deferred_canvas_host.hpp
new file mode 100644
index 0000000..92016bc
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/deferred_canvas_host.hpp
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include <cstdint>
+
+// Hook the scripting layer uses when a deferred host is recording. Stored on
+// the ScriptingContext as a forward declared pointer so the script headers
+// stay decoupled from the cmd layer; DeferredSession implements it.
+namespace rive
+{
+class Renderer;
+namespace gpu
+{
+class RenderCanvas;
+}
+
+namespace cmd
+{
+
+class DeferredCanvasHost
+{
+public:
+    virtual ~DeferredCanvasHost() = default;
+
+    // Emits the content begin bracket and returns a recording renderer owned
+    // by the host and valid until endCanvasContent. clearColor is ARGB.
+    virtual Renderer* beginCanvasContent(gpu::RenderCanvas* canvas,
+                                         uint32_t clearColor) = 0;
+
+    // Emits the content end bracket and releases the renderer.
+    virtual void endCanvasContent(gpu::RenderCanvas* canvas) = 0;
+};
+
+} // namespace cmd
+} // namespace rive
diff --git a/renderer/include/rive/renderer/cmd/deferred_render_factory.hpp b/renderer/include/rive/renderer/cmd/deferred_render_factory.hpp
new file mode 100644
index 0000000..c77b57a
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/deferred_render_factory.hpp
@@ -0,0 +1,498 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/factory.hpp"
+#include "rive/renderer.hpp"
+#include "rive/shapes/paint/image_sampler.hpp"
+#include "rive/renderer/cmd/foreign_image_registry.hpp"
+#include "rive/renderer/cmd/id_allocator.hpp"
+#include "rive/renderer/cmd/deferred_render_resource.hpp"
+#include "rive/renderer/cmd/render_command_buffer.hpp"
+#include "rive/renderer/cmd/render_commands.hpp"
+#include <cassert>
+#include <cstdio>
+#ifdef RIVE_DECODERS
+#include "rive/decoders/bitmap_decoder.hpp"
+#endif
+
+// DeferredFactory and DeferredRenderer are the 2D recording front end. make*
+// assigns a dense id and records a creation command; draws reference resources
+// by id. Nothing touches the GPU; the render side replays the single ordered
+// stream against a real Factory and Renderer.
+namespace rive::cmd
+{
+
+// Cheap encoded image dimension sniff. Builds without RIVE_DECODERS still
+// need record time width and height for layout. Defined in
+// src/deferred_cmd.cpp.
+bool sniffImageSize(Span<const uint8_t> b, int& w, int& h);
+
+class DeferredFactory : public Factory
+{
+public:
+    DeferredFactory()
+    {
+        // Stream order consumes each create destroy pair, so a recycled id
+        // never aliases a live one.
+        registerRecorder(&m_buffer);
+    }
+
+    ~DeferredFactory()
+    {
+        // Unregister first so resources a straggling wrapper still holds no-op
+        // their release instead of writing into a dead recorder.
+        unregisterRecorder(&m_buffer);
+        // Apply destroys queued from other threads before the stream dies.
+        m_buffer.drainDestroys();
+    }
+
+    // Creates, mutations, draws, and destroys all record into one ordered
+    // stream so a single replay pass suffices and id reuse stays correct.
+    std::unique_ptr<Renderer> makeRenderer(
+        ForeignImageRegistry* canvases = nullptr);
+    const RenderCommandBuffer& commandBuffer() const { return m_buffer; }
+    RenderCommandBuffer& commandBuffer() { return m_buffer; }
+
+    // Clear the ordered stream for the next frame; consumer keeps resources.
+    void resetFrame()
+    {
+        m_buffer.reset();
+        // Drain cross thread GC destroys into the new frame's stream head.
+        m_buffer.drainDestroys();
+    }
+
+    rcp<RenderPath> makeRenderPath(RawPath& path, FillRule fr) override
+    {
+        auto a = m_pathIds.alloc();
+        auto verbs = path.verbs();
+        auto points = path.points();
+        uint64_t verbsOff = m_buffer.appendBlob(
+            verbs.data(),
+            static_cast<uint32_t>(verbs.size() * sizeof(PathVerb)));
+        uint64_t pointsOff = m_buffer.appendBlob(
+            points.data(),
+            static_cast<uint32_t>(points.size() * sizeof(Vec2D)));
+        m_buffer.append(static_cast<uint8_t>(RenderCmd::makePath),
+                        MakePathPOD{a.id,
+                                    a.generation,
+                                    verbsOff,
+                                    pointsOff,
+                                    static_cast<uint32_t>(verbs.size()),
+                                    static_cast<uint32_t>(points.size()),
+                                    static_cast<uint32_t>(fr)});
+        return make_rcp<DeferredRenderPath>(a.id,
+                                            a.generation,
+                                            &m_buffer,
+                                            &m_pathIds);
+    }
+
+    rcp<RenderPath> makeEmptyRenderPath() override
+    {
+        auto a = m_pathIds.alloc();
+        m_buffer.append(static_cast<uint8_t>(RenderCmd::makeEmptyPath),
+                        MakeIdPOD{a.id, a.generation});
+        return make_rcp<DeferredRenderPath>(a.id,
+                                            a.generation,
+                                            &m_buffer,
+                                            &m_pathIds);
+    }
+
+    rcp<RenderPaint> makeRenderPaint() override
+    {
+        auto a = m_paintIds.alloc();
+        m_buffer.append(static_cast<uint8_t>(RenderCmd::makePaint),
+                        MakeIdPOD{a.id, a.generation});
+        return make_rcp<DeferredRenderPaint>(a.id,
+                                             a.generation,
+                                             &m_buffer,
+                                             &m_paintIds);
+    }
+
+    rcp<RenderShader> makeLinearGradient(float sx,
+                                         float sy,
+                                         float ex,
+                                         float ey,
+                                         const ColorInt colors[],
+                                         const float stops[],
+                                         size_t count) override
+    {
+        auto a = m_shaderIds.alloc();
+        GradientBlobs g = appendGradientStops(colors, stops, count);
+        m_buffer.append(static_cast<uint8_t>(RenderCmd::makeLinearGradient),
+                        LinearGradientPOD{a.id,
+                                          a.generation,
+                                          sx,
+                                          sy,
+                                          ex,
+                                          ey,
+                                          g.colorsOffset,
+                                          g.stopsOffset,
+                                          static_cast<uint32_t>(count)});
+        return make_rcp<DeferredRenderShader>(a.id,
+                                              a.generation,
+                                              &m_buffer,
+                                              &m_shaderIds);
+    }
+    rcp<RenderShader> makeRadialGradient(float cx,
+                                         float cy,
+                                         float radius,
+                                         const ColorInt colors[],
+                                         const float stops[],
+                                         size_t count) override
+    {
+        auto a = m_shaderIds.alloc();
+        GradientBlobs g = appendGradientStops(colors, stops, count);
+        m_buffer.append(static_cast<uint8_t>(RenderCmd::makeRadialGradient),
+                        RadialGradientPOD{a.id,
+                                          a.generation,
+                                          cx,
+                                          cy,
+                                          radius,
+                                          static_cast<uint32_t>(count),
+                                          g.colorsOffset,
+                                          g.stopsOffset});
+        return make_rcp<DeferredRenderShader>(a.id,
+                                              a.generation,
+                                              &m_buffer,
+                                              &m_shaderIds);
+    }
+
+    rcp<RenderBuffer> makeRenderBuffer(RenderBufferType type,
+                                       RenderBufferFlags flags,
+                                       size_t sizeInBytes) override
+    {
+        auto a = m_bufferIds.alloc();
+        m_buffer.append(static_cast<uint8_t>(RenderCmd::makeBuffer),
+                        MakeBufferPOD{a.id,
+                                      a.generation,
+                                      static_cast<uint8_t>(type),
+                                      static_cast<uint8_t>(flags),
+                                      static_cast<uint32_t>(sizeInBytes)});
+        // The resident buffer keeps its data across frames on the consumer.
+        return make_rcp<DeferredRenderBuffer>(a.id,
+                                              a.generation,
+                                              type,
+                                              flags,
+                                              sizeInBytes,
+                                              &m_buffer,
+                                              &m_bufferIds);
+    }
+
+    rcp<RenderImage> decodeImage(Span<const uint8_t> bytes) override
+    {
+        auto a = m_imageIds.alloc();
+        // Decode dims at record time so the artboard can read them during
+        // advance; the render side uploads the real texture.
+        int w = 0, h = 0;
+#ifdef RIVE_DECODERS
+        if (auto bm = Bitmap::decode(bytes.data(), bytes.size()))
+        {
+            w = static_cast<int>(bm->width());
+            h = static_cast<int>(bm->height());
+        }
+#endif
+        // wasm builds decode in the browser, so sniff dims from the header.
+        if (w == 0 || h == 0)
+        {
+            sniffImageSize(bytes, w, h);
+        }
+#ifndef NDEBUG
+        // Unknown dims silently break layout far from here, so warn once.
+        if (w == 0 || h == 0)
+        {
+            static bool warned = false;
+            if (!warned)
+            {
+                warned = true;
+                fprintf(
+                    stderr,
+                    "DeferredFactory::decodeImage: image dims unknown at "
+                    "record time (decode failed, or built without "
+                    "RIVE_DECODERS); size-dependent layout will be wrong\n");
+            }
+        }
+#endif
+        uint64_t off = m_buffer.appendBlob(bytes.data(),
+                                           static_cast<uint32_t>(bytes.size()));
+        m_buffer.append(static_cast<uint8_t>(RenderCmd::decodeImage),
+                        DecodeImagePOD{a.id,
+                                       a.generation,
+                                       off,
+                                       static_cast<uint32_t>(bytes.size()),
+                                       static_cast<uint32_t>(w),
+                                       static_cast<uint32_t>(h)});
+        return make_rcp<DeferredRenderImage>(a.id,
+                                             a.generation,
+                                             w,
+                                             h,
+                                             &m_buffer,
+                                             &m_imageIds);
+    }
+
+private:
+    // colors[count] (ColorInt) and stops[count] (float), each its own blob.
+    struct GradientBlobs
+    {
+        uint64_t colorsOffset;
+        uint64_t stopsOffset;
+    };
+    GradientBlobs appendGradientStops(const ColorInt colors[],
+                                      const float stops[],
+                                      size_t count)
+    {
+        GradientBlobs g;
+        g.colorsOffset = m_buffer.appendBlob(
+            colors,
+            static_cast<uint32_t>(count * sizeof(ColorInt)));
+        g.stopsOffset =
+            m_buffer.appendBlob(stops,
+                                static_cast<uint32_t>(count * sizeof(float)));
+        return g;
+    }
+
+    RenderCommandBuffer
+        m_buffer; // ordered: creates + mutations + draws + destroys
+    // One reusable id space per resource type (Dawn-style free list + gen).
+    IdAllocator<RenderHandle> m_pathIds;
+    IdAllocator<RenderHandle> m_paintIds;
+    IdAllocator<RenderHandle> m_shaderIds;
+    IdAllocator<RenderHandle> m_imageIds;
+    IdAllocator<RenderHandle> m_bufferIds;
+};
+
+// Draws are attributed to the renderer that issues them, not their stream
+// position, so scripts can interleave canvases and the screen freely.
+class DeferredRouteHost
+{
+public:
+    virtual void routeTo(uint64_t target) = 0;
+
+protected:
+    ~DeferredRouteHost() = default;
+};
+// Canvases and screens share one route target space: a canvas uses its
+// unflagged canvas id, a screen sets this flag over its target id. One session
+// records for every screen target its render context drives, so a screen needs
+// an identity a canvas id cannot alias.
+constexpr uint64_t kScreenTargetFlag = 1ull << 63;
+constexpr uint64_t kScreenTarget = kScreenTargetFlag; // screen target 0
+constexpr uint64_t screenTarget(uint64_t id) { return kScreenTargetFlag | id; }
+constexpr bool isScreenTarget(uint64_t target)
+{
+    return (target & kScreenTargetFlag) != 0;
+}
+constexpr uint64_t screenTargetId(uint64_t target)
+{
+    return target & ~kScreenTargetFlag;
+}
+
+class DeferredRenderer : public Renderer
+{
+public:
+    explicit DeferredRenderer(RenderCommandBuffer* buffer,
+                              ForeignImageRegistry* canvases = nullptr,
+                              DeferredRouteHost* routeHost = nullptr,
+                              uint64_t routeTarget = kScreenTarget) :
+        m_buffer(buffer),
+        m_canvases(canvases),
+        m_routeHost(routeHost),
+        m_routeTarget(routeTarget)
+    {}
+
+    void save() override
+    {
+        route();
+        m_buffer->appendType(static_cast<uint8_t>(RenderCmd::save));
+    }
+    void restore() override
+    {
+        route();
+        m_buffer->appendType(static_cast<uint8_t>(RenderCmd::restore));
+    }
+    void transform(const Mat2D& m) override
+    {
+        route();
+        m_buffer->append(
+            static_cast<uint8_t>(RenderCmd::transform),
+            TransformPOD{m.xx(), m.xy(), m.yx(), m.yy(), m.tx(), m.ty()});
+    }
+    void drawPath(RenderPath* path, RenderPaint* paint) override
+    {
+        DeferredRenderPath::flushScratchOf(path);
+        RenderHandle pathId = DeferredRenderPath::idOfPath(path);
+        RenderHandle paintId = idOfPaint(paint);
+        if (pathId == kInvalidRenderHandle || paintId == kInvalidRenderHandle)
+        {
+            // A foreign draw is dropped at replay anyway; recording it would
+            // flood the stream every frame.
+            warnForeign("drawPath");
+            return;
+        }
+        auto* dp = lite_rtti_cast<DeferredRenderPath*>(path);
+        auto* dpt = lite_rtti_cast<DeferredRenderPaint*>(paint);
+        dp->markDrawn();
+        dpt->markDrawn();
+        route();
+        m_buffer->append(
+            static_cast<uint8_t>(RenderCmd::drawPath),
+            DrawPathPOD{pathId, paintId, dp->version(), dpt->version()});
+    }
+    void clipPath(RenderPath* path) override
+    {
+        DeferredRenderPath::flushScratchOf(path);
+        auto* dp = lite_rtti_cast<DeferredRenderPath*>(path);
+        if (dp != nullptr)
+        {
+            dp->markDrawn();
+        }
+        route();
+        m_buffer->append(static_cast<uint8_t>(RenderCmd::clipPath),
+                         ClipPathPOD{DeferredRenderPath::idOfPath(path),
+                                     dp != nullptr ? dp->version() : 0});
+    }
+    void modulateOpacity(float opacity) override
+    {
+        route();
+        m_buffer->append(static_cast<uint8_t>(RenderCmd::modulateOpacity),
+                         OpacityPOD{opacity});
+    }
+
+    void drawImage(const RenderImage* image,
+                   ImageSampler s,
+                   BlendMode blend,
+                   float opacity) override
+    {
+        // Canvas images get a flagged id from the registry on first sight.
+        RenderHandle id = idOfImage(image);
+        if (id == kInvalidRenderHandle && m_canvases)
+        {
+            id = m_canvases->imageDrawId(const_cast<RenderImage*>(image));
+        }
+        if (id == kInvalidRenderHandle)
+        {
+            // Foreign image is dropped at replay anyway, skip recording.
+            warnForeign("drawImage");
+            return;
+        }
+        route();
+        m_buffer->append(static_cast<uint8_t>(RenderCmd::drawImage),
+                         DrawImagePOD{id,
+                                      static_cast<uint8_t>(s.wrapX),
+                                      static_cast<uint8_t>(s.wrapY),
+                                      static_cast<uint8_t>(s.filter),
+                                      static_cast<uint8_t>(blend),
+                                      opacity});
+    }
+    void drawImageMesh(const RenderImage* image,
+                       ImageSampler s,
+                       rcp<RenderBuffer> vertices,
+                       rcp<RenderBuffer> uvCoords,
+                       rcp<RenderBuffer> indices,
+                       uint32_t vertexCount,
+                       uint32_t indexCount,
+                       BlendMode blend,
+                       float opacity) override
+    {
+        RenderHandle imgId = idOfImage(image);
+        if (imgId == kInvalidRenderHandle && m_canvases)
+        {
+            // A real image decoded outside the session (the host's decode
+            // wrap makes that legitimate) rides to replay via the registry.
+            imgId = m_canvases->imageDrawId(const_cast<RenderImage*>(image));
+        }
+        RenderHandle vId = idOfBuffer(vertices.get());
+        RenderHandle uvId = idOfBuffer(uvCoords.get());
+        RenderHandle idxId = idOfBuffer(indices.get());
+        bool foreign =
+            imgId == kInvalidRenderHandle || vId == kInvalidRenderHandle ||
+            uvId == kInvalidRenderHandle || idxId == kInvalidRenderHandle;
+        if (foreign)
+        {
+            // Foreign mesh is dropped at replay anyway, skip recording.
+            warnForeign("drawImageMesh");
+            return;
+        }
+        auto* dv = lite_rtti_cast<DeferredRenderBuffer*>(vertices.get());
+        auto* duv = lite_rtti_cast<DeferredRenderBuffer*>(uvCoords.get());
+        auto* di = lite_rtti_cast<DeferredRenderBuffer*>(indices.get());
+        dv->markDrawn();
+        duv->markDrawn();
+        di->markDrawn();
+        route();
+        m_buffer->append(static_cast<uint8_t>(RenderCmd::drawImageMesh),
+                         DrawImageMeshPOD{imgId,
+                                          vId,
+                                          uvId,
+                                          idxId,
+                                          dv->version(),
+                                          duv->version(),
+                                          di->version(),
+                                          vertexCount,
+                                          indexCount,
+                                          static_cast<uint8_t>(s.wrapX),
+                                          static_cast<uint8_t>(s.wrapY),
+                                          static_cast<uint8_t>(s.filter),
+                                          static_cast<uint8_t>(blend),
+                                          opacity});
+    }
+
+private:
+    // A foreign resource means the caller mixed factories; that is always a
+    // bug worth surfacing.
+    static void warnForeign(const char* what)
+    {
+        static int warned = 0;
+        if (warned < 16)
+        {
+            warned = warned + 1;
+            fprintf(stderr,
+                    "rive deferred: %s with a foreign resource (made by a "
+                    "different factory), draw will be dropped\n",
+                    what);
+        }
+    }
+
+    static RenderHandle idOfPaint(const RenderPaint* p)
+    {
+        auto* d =
+            lite_rtti_cast<DeferredRenderPaint*>(const_cast<RenderPaint*>(p));
+        return d ? d->id() : kInvalidRenderHandle;
+    }
+    static RenderHandle idOfImage(const RenderImage* i)
+    {
+        auto* d =
+            lite_rtti_cast<DeferredRenderImage*>(const_cast<RenderImage*>(i));
+        return d ? d->id() : kInvalidRenderHandle;
+    }
+    static RenderHandle idOfBuffer(const RenderBuffer* b)
+    {
+        auto* d =
+            lite_rtti_cast<DeferredRenderBuffer*>(const_cast<RenderBuffer*>(b));
+        return d ? d->id() : kInvalidRenderHandle;
+    }
+    // Attribute the coming op to this recorder's target; standalone recorders
+    // have no host and skip it.
+    void route()
+    {
+        if (m_routeHost != nullptr)
+        {
+            m_routeHost->routeTo(m_routeTarget);
+        }
+    }
+
+    RenderCommandBuffer* m_buffer;
+    ForeignImageRegistry* m_canvases;
+    DeferredRouteHost* m_routeHost;
+    uint64_t m_routeTarget;
+};
+
+inline std::unique_ptr<Renderer> DeferredFactory::makeRenderer(
+    ForeignImageRegistry* canvases)
+{
+    return std::make_unique<DeferredRenderer>(&m_buffer, canvases);
+}
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/deferred_render_resource.hpp b/renderer/include/rive/renderer/cmd/deferred_render_resource.hpp
new file mode 100644
index 0000000..564005f
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/deferred_render_resource.hpp
@@ -0,0 +1,487 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer.hpp"
+#include "rive/command_path.hpp"
+#include "rive/math/raw_path.hpp"
+#include "rive/math/mat2d.hpp"
+#include "utils/lite_rtti.hpp"
+#include "rive/renderer/cmd/render_command_buffer.hpp"
+#include "rive/renderer/cmd/render_commands.hpp"
+#include "rive/renderer/cmd/id_allocator.hpp"
+#include "rive/renderer/cmd/live_recorder_registry.hpp"
+#include <cstdio>
+#include <mutex>
+
+// Deferred 2D resources: lite_rtti subclasses of the real Factory types that
+// carry a dense creation id and record their mutations into a shared
+// RenderCommandBuffer. The render side recreates the real resource from the
+// stream and replays the mutations in order.
+namespace rive::cmd
+{
+
+// The destroy lands in stream order after the draws that referenced the id,
+// making reuse safe. A release after the session died no-ops.
+inline void releaseDeferred(RenderCommandBuffer* commands,
+                            IdAllocator<RenderHandle>* allocator,
+                            ResourceKind kind,
+                            RenderHandle id,
+                            uint32_t generation)
+{
+    std::lock_guard<std::mutex> lock(recorderRegistryMutex());
+    if (liveRecorders().count(commands) == 0)
+    {
+        return; // the session died first, nothing to record into
+    }
+    // Destructors run on any thread, so queue rather than append; the
+    // recording thread drains at the frame boundary.
+    commands->queueDestroy(static_cast<uint8_t>(kind),
+                           id,
+                           generation,
+                           allocator);
+}
+
+// Common deferred resource state: the dense creation id other streams
+// reference it by, and the destroy recorded at destruction.
+class DeferredResourceBase
+{
+public:
+    DeferredResourceBase(ResourceKind kind,
+                         RenderHandle id,
+                         uint32_t generation,
+                         RenderCommandBuffer* buffer,
+                         IdAllocator<RenderHandle>* allocator) :
+        m_id(id),
+        m_buffer(buffer),
+        m_generation(generation),
+        m_allocator(allocator),
+        m_kind(kind)
+    {}
+    RenderHandle id() const { return m_id; }
+
+protected:
+    ~DeferredResourceBase()
+    {
+        releaseDeferred(m_buffer, m_allocator, m_kind, m_id, m_generation);
+    }
+
+    RenderHandle m_id;
+    RenderCommandBuffer* m_buffer;
+
+    ResourceKind kind() const { return m_kind; }
+
+private:
+    uint32_t m_generation;
+    IdAllocator<RenderHandle>* m_allocator;
+    ResourceKind m_kind;
+};
+
+// Adds the drawn-version pinning: draws pin the version they recorded
+// against, so a mutation of a resource drawn this frame bumps to a new
+// version; the first mutation of a new frame reuses the live replay object.
+class VersionedDeferredResource : public DeferredResourceBase
+{
+public:
+    using DeferredResourceBase::DeferredResourceBase;
+
+    uint32_t version() const { return m_version; }
+    void markDrawn() { m_drawnFrame = m_buffer->frameId(); }
+
+protected:
+    void bump()
+    {
+        if (m_drawnFrame != m_buffer->frameId())
+        {
+            return; // not drawn this frame: mutate the live object in place
+        }
+        m_version++;
+        m_drawnFrame = kNeverDrawn;
+        m_buffer->append(
+            static_cast<uint8_t>(RenderCmd::resourceNewVersion),
+            ResourceVersionPOD{static_cast<uint8_t>(kind()), m_id, m_version});
+    }
+
+private:
+    uint32_t m_version = 0;
+    static constexpr uint32_t kNeverDrawn = ~0u;
+    uint32_t m_drawnFrame = kNeverDrawn;
+};
+
+// RenderShader is only ever a gradient, so a deferred shader is just an id.
+class DeferredRenderShader
+    : public LITE_RTTI_OVERRIDE(RenderShader, DeferredRenderShader),
+      public DeferredResourceBase
+{
+public:
+    DeferredRenderShader(RenderHandle id,
+                         uint32_t generation,
+                         RenderCommandBuffer* commands,
+                         IdAllocator<RenderHandle>* allocator) :
+        DeferredResourceBase(ResourceKind::shader,
+                             id,
+                             generation,
+                             commands,
+                             allocator)
+    {}
+};
+
+class DeferredRenderPaint
+    : public LITE_RTTI_OVERRIDE(RenderPaint, DeferredRenderPaint),
+      public VersionedDeferredResource
+{
+public:
+    DeferredRenderPaint(RenderHandle id,
+                        uint32_t generation,
+                        RenderCommandBuffer* buffer,
+                        IdAllocator<RenderHandle>* allocator) :
+        VersionedDeferredResource(ResourceKind::paint,
+                                  id,
+                                  generation,
+                                  buffer,
+                                  allocator)
+    {}
+
+    void style(RenderPaintStyle v) override
+    {
+        if (absorbed(m_state.style, static_cast<uint8_t>(v)))
+        {
+            return;
+        }
+        emitU8(RenderCmd::paintStyle, m_state.style);
+    }
+    void color(ColorInt v) override
+    {
+        if (absorbed(m_state.color, v) && m_colorKnown)
+        {
+            return;
+        }
+        m_colorKnown = true;
+        bump();
+        m_buffer->append(t(RenderCmd::paintColor),
+                         PaintColorPOD{m_id, m_state.color});
+    }
+    void thickness(float v) override
+    {
+        if (absorbed(m_state.thickness, v))
+        {
+            return;
+        }
+        emitFloat(RenderCmd::paintThickness, m_state.thickness);
+    }
+    void join(StrokeJoin v) override
+    {
+        if (absorbed(m_state.join, static_cast<uint8_t>(v)))
+        {
+            return;
+        }
+        emitU8(RenderCmd::paintJoin, m_state.join);
+    }
+    void cap(StrokeCap v) override
+    {
+        if (absorbed(m_state.cap, static_cast<uint8_t>(v)))
+        {
+            return;
+        }
+        emitU8(RenderCmd::paintCap, m_state.cap);
+    }
+    void feather(float v) override
+    {
+        if (absorbed(m_state.feather, v))
+        {
+            return;
+        }
+        emitFloat(RenderCmd::paintFeather, m_state.feather);
+    }
+    void blendMode(BlendMode v) override
+    {
+        if (absorbed(m_state.blendMode, static_cast<uint8_t>(v)))
+        {
+            return;
+        }
+        emitU8(RenderCmd::paintBlendMode, m_state.blendMode);
+    }
+    void shader(
+        rcp<RenderShader> s) override; // defined below (needs the helper)
+    void invalidateStroke() override
+    {
+        // Stroked shapes invalidate every frame their path moves, and the
+        // consumer only rebuilds the stroke when it draws, so repeats before
+        // the next draw say nothing new. It carries no state either, so it
+        // must not drag a version bump and a state rewrite behind it.
+        if (m_strokeInvalidated)
+        {
+            return;
+        }
+        m_strokeInvalidated = true;
+        m_buffer->append(t(RenderCmd::paintInvalidateStroke), ResIdPOD{m_id});
+    }
+
+    void markDrawn()
+    {
+        VersionedDeferredResource::markDrawn();
+        m_strokeInvalidated = false;
+    }
+
+private:
+    // Must be the backend's defaults, because a property nothing ever changes
+    // away from them is never written and the replay object keeps its own.
+    struct State
+    {
+        ColorInt color = 0xFF000000;
+        float thickness = 1;
+        float feather = 0;
+        uint8_t style = 1;     // fill; a fresh paint is unstroked until told
+        uint8_t join = 0;      // miter
+        uint8_t cap = 0;       // butt
+        uint8_t blendMode = 3; // srcOver
+    };
+
+    static uint8_t t(RenderCmd c) { return static_cast<uint8_t>(c); }
+    // Absorbs the new value into the shadow; true when the consumer already
+    // has it and the append can be skipped.
+    template <typename T> bool absorbed(T& field, T v)
+    {
+        if (field == v)
+        {
+            return true;
+        }
+        field = v;
+        return false;
+    }
+    void emitU8(RenderCmd c, uint8_t v)
+    {
+        bump();
+        m_buffer->append(t(c), PaintU8POD{m_id, v});
+    }
+    void emitFloat(RenderCmd c, float v)
+    {
+        bump();
+        m_buffer->append(t(c), PaintFloatPOD{m_id, v});
+    }
+
+    State m_state;
+    // Held as an rcp so an animated gradient outlives the setter: comparing
+    // ids alone would alias a recycled one.
+    rcp<RenderShader> m_shader;
+    bool m_colorKnown = true;
+    bool m_strokeInvalidated = false;
+};
+
+class DeferredRenderPath
+    : public LITE_RTTI_OVERRIDE(RenderPath, DeferredRenderPath),
+      public VersionedDeferredResource
+{
+public:
+    DeferredRenderPath(RenderHandle id,
+                       uint32_t generation,
+                       RenderCommandBuffer* buffer,
+                       IdAllocator<RenderHandle>* allocator) :
+        VersionedDeferredResource(ResourceKind::path,
+                                  id,
+                                  generation,
+                                  buffer,
+                                  allocator)
+    {}
+
+    void rewind() override
+    {
+        bump();
+        m_scratch.rewind(); // discards any pending per-verb geometry
+        m_buffer->append(t(RenderCmd::pathRewind), ResIdPOD{m_id});
+    }
+    void fillRule(FillRule v) override
+    {
+        // ShapePaint re-sets the fill rule before every fill draw; a no-op
+        // set must not bump a drawn path to a new version.
+        if (m_haveFillRule && v == m_fillRule)
+        {
+            return;
+        }
+        m_haveFillRule = true;
+        m_fillRule = v;
+        bump();
+        m_buffer->append(t(RenderCmd::pathFillRule),
+                         PathFillRulePOD{m_id, static_cast<uint8_t>(v)});
+    }
+
+    // Per verb builders are never hit by app content but the interface
+    // requires them; accumulate into a scratch RawPath flushed on next use.
+    void moveTo(float x, float y) override { m_scratch.moveTo(x, y); }
+    void lineTo(float x, float y) override { m_scratch.lineTo(x, y); }
+    void cubicTo(float ox, float oy, float ix, float iy, float x, float y)
+        override
+    {
+        m_scratch.cubicTo(ox, oy, ix, iy, x, y);
+    }
+    void close() override { m_scratch.close(); }
+
+    void addRenderPath(const RenderPath* path, const Mat2D& m) override
+    {
+        bump();
+        flushScratch();
+        flushScratchOf(path); // src geometry must be complete in the stream
+        RenderHandle src = idOfPath(path);
+        m_buffer->append(t(RenderCmd::pathAddRenderPath),
+                         PathAddPathPOD{m_id,
+                                        src,
+                                        m.xx(),
+                                        m.xy(),
+                                        m.yx(),
+                                        m.yy(),
+                                        m.tx(),
+                                        m.ty()});
+    }
+    void addRawPath(const RawPath& path) override
+    {
+        flushScratch(); // preserve order: pending per-verb before this bulk add
+        recordAddRawPath(path);
+    }
+
+    // Emit any pending per verb geometry as one addRawPath. Public so the
+    // renderer can flush before a draw or clip.
+    void flushScratch()
+    {
+        if (m_scratch.empty())
+        {
+            return;
+        }
+        recordAddRawPath(m_scratch);
+        m_scratch.rewind();
+    }
+    static void flushScratchOf(const RenderPath* p)
+    {
+        if (auto* d =
+                lite_rtti_cast<DeferredRenderPath*>(const_cast<RenderPath*>(p)))
+        {
+            d->flushScratch();
+        }
+    }
+
+    // kInvalid when the path is not one of ours.
+    static RenderHandle idOfPath(const RenderPath* p)
+    {
+        auto* d =
+            lite_rtti_cast<DeferredRenderPath*>(const_cast<RenderPath*>(p));
+        return d ? d->id() : kInvalidRenderHandle;
+    }
+
+private:
+    static uint8_t t(RenderCmd c) { return static_cast<uint8_t>(c); }
+    // Replay seeds a bumped path version from the outgoing one, so appends
+    // land on prior geometry and a rewind clears the seed via its own
+    // recorded command.
+    void recordAddRawPath(const RawPath& path)
+    {
+        bump();
+        auto verbs = path.verbs();
+        auto points = path.points();
+        uint64_t verbsOff = m_buffer->appendBlob(
+            verbs.data(),
+            static_cast<uint32_t>(verbs.size() * sizeof(PathVerb)));
+        uint64_t pointsOff = m_buffer->appendBlob(
+            points.data(),
+            static_cast<uint32_t>(points.size() * sizeof(Vec2D)));
+        m_buffer->append(t(RenderCmd::pathAddRawPath),
+                         PathRawPOD{verbsOff,
+                                    pointsOff,
+                                    m_id,
+                                    static_cast<uint32_t>(verbs.size()),
+                                    static_cast<uint32_t>(points.size())});
+    }
+
+    RawPath m_scratch; // pending CommandPath per-verb geometry
+    FillRule m_fillRule = FillRule::nonZero;
+    bool m_haveFillRule = false;
+};
+
+inline void DeferredRenderPaint::shader(rcp<RenderShader> s)
+{
+    if (m_shader.get() == s.get())
+    {
+        return;
+    }
+    m_shader = std::move(s);
+    RenderHandle id = kInvalidRenderHandle; // null clears the shader
+    if (auto* d = lite_rtti_cast<DeferredRenderShader*>(m_shader.get()))
+    {
+        id = d->id();
+    }
+    // Backends are free to disturb the solid color when the shader moves
+    // (RiveRenderPaint does), so stop trusting the shadowed one.
+    m_colorKnown = false;
+    bump();
+    m_buffer->append(t(RenderCmd::paintShader), PaintShaderPOD{m_id, id});
+}
+
+// Carries dims decoded at record time so the artboard can read them during
+// advance; the real GPU image is uploaded on the render side.
+class DeferredRenderImage
+    : public LITE_RTTI_OVERRIDE(RenderImage, DeferredRenderImage),
+      public DeferredResourceBase
+{
+public:
+    DeferredRenderImage(RenderHandle id,
+                        uint32_t generation,
+                        int width,
+                        int height,
+                        RenderCommandBuffer* commands,
+                        IdAllocator<RenderHandle>* allocator) :
+        DeferredResourceBase(ResourceKind::image,
+                             id,
+                             generation,
+                             commands,
+                             allocator)
+    {
+        m_Width = width;
+        m_Height = height;
+    }
+};
+
+// map() hands out a scratch buffer; unmap() records its bytes as a bufferData
+// command replayed on the render side.
+class DeferredRenderBuffer
+    : public LITE_RTTI_OVERRIDE(RenderBuffer, DeferredRenderBuffer),
+      public VersionedDeferredResource
+{
+public:
+    DeferredRenderBuffer(RenderHandle id,
+                         uint32_t generation,
+                         RenderBufferType type,
+                         RenderBufferFlags flags,
+                         size_t sizeInBytes,
+                         RenderCommandBuffer* buffer,
+                         IdAllocator<RenderHandle>* allocator) :
+        LITE_RTTI_OVERRIDE(RenderBuffer,
+                           DeferredRenderBuffer)(type, flags, sizeInBytes),
+        VersionedDeferredResource(ResourceKind::buffer,
+                                  id,
+                                  generation,
+                                  buffer,
+                                  allocator)
+    {}
+
+protected:
+    void* onMap() override
+    {
+        m_scratch.resize(sizeInBytes());
+        return m_scratch.data();
+    }
+    void onUnmap() override
+    {
+        bump();
+        uint64_t off =
+            m_buffer->appendBlob(m_scratch.data(),
+                                 static_cast<uint32_t>(m_scratch.size()));
+        m_buffer->append(
+            static_cast<uint8_t>(RenderCmd::bufferData),
+            BufferDataPOD{off, m_id, static_cast<uint32_t>(m_scratch.size())});
+    }
+
+private:
+    std::vector<uint8_t> m_scratch;
+};
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/deferred_replayer.hpp b/renderer/include/rive/renderer/cmd/deferred_replayer.hpp
new file mode 100644
index 0000000..9d8a62c
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/deferred_replayer.hpp
@@ -0,0 +1,372 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/canvas_schedule.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "rive/renderer/cmd/gpu_census.hpp"
+#include "rive/renderer/cmd/render_replay.hpp"
+#include "rive/renderer/ore/cmd/ore_replay.hpp"
+#include <algorithm>
+#include <deque>
+#include <mutex>
+#include <unordered_map>
+#include <vector>
+
+// DeferredReplayer consumes a recorded deferred frame, replaying the 2D and
+// Ore streams against real resources through a DeferredFrameSink. Reusing one
+// replayer keeps resources resident frame to frame. It makes no thread
+// assumptions; the sink is the threading seam.
+namespace rive::cmd
+{
+
+// Host supplied GPU frame operations, kept behind an interface so the replayer
+// stays backend and window agnostic.
+class DeferredFrameSink
+{
+public:
+    virtual ~DeferredFrameSink() = default;
+
+    // The real Factory resources replay against.
+    virtual Factory* factory() = 0;
+
+    // Defaults to the factory's ore context; hosts whose factory does not own
+    // one override it.
+    virtual ore::Context* oreContext() { return factory()->ore(); }
+
+    // Open the screen frame for one render target and return its renderer.
+    // A session drives every target its render context owns, so replay asks
+    // once per target it recorded for. Left open for the caller to present.
+    virtual Renderer* beginScreenFrame(uint64_t target) = 0;
+
+    // Which target claims work no screen segment attributed, such as an Ore
+    // only or canvas only frame. A sink serving one texture answers with that
+    // texture's target, since target 0 may belong to a different one.
+    virtual uint64_t defaultScreenTarget() { return 0; }
+
+    // Bracket the recorded Ore passes with the backend's Ore frame.
+    virtual void beginOreFrame() {}
+    virtual void endOreFrame() {}
+    // Backend state fix up after an Ore frame, such as a GL state invalidate.
+    virtual void afterOreFrame() {}
+
+    // Open the canvas's own frame and return the renderer its draws route
+    // into, or null to drop them. endCanvasContent flushes it.
+    virtual Renderer* beginCanvasContent(gpu::RenderCanvas* /*canvas*/,
+                                         uint32_t /*clearColor*/)
+    {
+        return nullptr;
+    }
+    virtual void endCanvasContent() {}
+};
+
+// Immutable snapshot of one recorded frame so the producer can record the next
+// frame while this one replays on a render thread.
+struct DeferredFrame
+{
+    std::vector<uint8_t> commands, blobs;       // 2D ordered stream
+    std::vector<uint8_t> oreCommands, oreBlobs; // Ore ordered stream
+    std::vector<rcp<RenderImage>> canvasImages; // unflagged canvas id -> image
+    std::unordered_map<RenderHandle, rcp<gpu::RenderCanvas>> contentCanvases;
+    std::vector<rcp<rive::gpu::GPUResource>> oreReals; // unflagged real id
+    // Assembled scheduler segments; byte ranges index the streams above.
+    std::vector<DeferredSegment> segments;
+};
+
+inline DeferredFrame snapshotFrame(DeferredSession& session)
+{
+    // An errored script can leave a canvas range open; close it before the
+    // bytes are copied.
+    session.closeOpenRange();
+    auto copy = [](Span<const uint8_t> s) {
+        return std::vector<uint8_t>(s.data(), s.data() + s.size());
+    };
+    DeferredFrame f;
+    f.commands = copy(session.commandBuffer().commandBytes());
+    f.blobs = copy(session.commandBuffer().blobBytes());
+    f.oreCommands = copy(session.oreContext().stream().commandBytes());
+    f.oreBlobs = copy(session.oreContext().stream().blobBytes());
+    f.canvasImages = session.canvases().images();
+    f.contentCanvases = session.contentCanvases();
+    f.oreReals = session.oreContext().realResources();
+    f.segments = session.schedulerSegments();
+    return f;
+}
+
+// Snapshot and clear as one step. A session hands over exactly one frame per
+// rendered frame however many targets it drove, so the reset that ends the
+// recording window belongs with the capture that reads it, not with any one
+// target's flush.
+inline DeferredFrame takeFrame(DeferredSession& session)
+{
+    DeferredFrame frame = snapshotFrame(session);
+    session.resetFrame();
+    return frame;
+}
+
+class DeferredReplayer
+{
+public:
+    // Inline zero copy form: replay straight out of the session. Leaves the
+    // screen frame open for the caller to present.
+    void replayFrame(DeferredSession& session, DeferredFrameSink& sink)
+    {
+        session.closeOpenRange();
+        replay(
+            session.commandBuffer().commandBytes(),
+            session.commandBuffer().blobBytes(),
+            session.oreContext().stream().commandBytes(),
+            session.oreContext().stream().blobBytes(),
+            [&](RenderHandle id) { return session.canvasImageAt(id); },
+            [&](RenderHandle id) { return session.contentCanvasAt(id); },
+            session.oreContext().realResources(),
+            sink,
+            session.schedulerSegments());
+    }
+
+    // Snapshot form: replay an owned frame, independent of the session.
+    void replayFrame(const DeferredFrame& frame, DeferredFrameSink& sink)
+    {
+        replay(
+            toSpan(frame.commands),
+            toSpan(frame.blobs),
+            toSpan(frame.oreCommands),
+            toSpan(frame.oreBlobs),
+            [&](RenderHandle id) -> RenderImage* {
+                return id < frame.canvasImages.size()
+                           ? frame.canvasImages[id].get()
+                           : nullptr;
+            },
+            [&](RenderHandle id) -> gpu::RenderCanvas* {
+                auto it = frame.contentCanvases.find(id);
+                return it == frame.contentCanvases.end() ? nullptr
+                                                         : it->second.get();
+            },
+            frame.oreReals,
+            sink,
+            frame.segments);
+    }
+
+    // Drop the resident tables so the next replay recreates everything.
+    void reset()
+    {
+        m_2d = ResourceTable{};
+        m_ore = ore::cmd::OreResident{};
+    }
+
+    ResourceTable& table() { return m_2d; }
+
+    // What the resident tables are holding on the GPU. Walks on demand and
+    // costs the record and replay paths nothing, but reads the tables, so the
+    // caller owes it a quiescent replayer.
+    GpuCensus gpuCensus() const { return takeGpuCensus(m_2d, m_ore); }
+
+private:
+    static Span<const uint8_t> toSpan(const std::vector<uint8_t>& v)
+    {
+        return Span<const uint8_t>(v.data(), v.size());
+    }
+
+    template <typename CanvasImageFn, typename ContentCanvasFn>
+    void replay(Span<const uint8_t> commands,
+                Span<const uint8_t> blobs,
+                Span<const uint8_t> oreCommands,
+                Span<const uint8_t> oreBlobs,
+                CanvasImageFn canvasImage,
+                ContentCanvasFn contentCanvas,
+                const std::vector<rcp<rive::gpu::GPUResource>>& oreReals,
+                DeferredFrameSink& sink,
+                const std::vector<DeferredSegment>& segments)
+    {
+        m_stats = ReplayStats{};
+        m_2d.clearVersionAliases();
+        ReplayHooks hooks;
+        hooks.stats = &m_stats;
+        hooks.canvasImage = canvasImage;
+        // A canvas's content may span several ranges; its real frame opens at
+        // the first range and flushes once its group ends below.
+        Renderer* openContentRenderer = nullptr;
+        RenderHandle openContentId = kInvalidRenderHandle;
+        hooks.beginCanvasContent = [&](RenderHandle id,
+                                       uint32_t clearColor) -> Renderer* {
+            if (id == openContentId)
+            {
+                return openContentRenderer; // later range, frame already open
+            }
+            gpu::RenderCanvas* canvas = contentCanvas(id);
+            openContentRenderer =
+                canvas ? sink.beginCanvasContent(canvas, clearColor) : nullptr;
+            openContentId = id;
+            return openContentRenderer;
+        };
+        // A stale ore replay marker in the 2D stream replays as a no-op.
+
+        auto subSpan = [](Span<const uint8_t> s, uint32_t begin, uint32_t end) {
+            return Span<const uint8_t>(s.data() + begin, end - begin);
+        };
+
+        // The whole Ore stream replays as one frame. Splitting it per pass
+        // would risk breaking a create use destroy resource lifecycle.
+        auto replayOre = [&]() {
+            if (oreCommands.empty())
+            {
+                return;
+            }
+            ore::Context* realOre = sink.oreContext();
+            if (realOre == nullptr)
+            {
+                // One shot content like a canvas wrap never re-records, so a
+                // skipped stream is permanent loss, never silent.
+                RIVE_WARN_THROTTLED(
+                    "rive deferred: no ore context, dropping %zu ore command "
+                    "bytes (canvas content will be lost)\n",
+                    oreCommands.size());
+                return;
+            }
+            sink.beginOreFrame();
+            ore::cmd::replayOreStream(
+                *realOre,
+                oreCommands,
+                oreBlobs,
+                m_ore,
+                [&](ore::cmd::ResourceHandle h) -> rive::gpu::GPUResource* {
+                    ore::cmd::ResourceHandle i =
+                        h & ore::cmd::kRealResourceMask;
+                    return i < oreReals.size() ? oreReals[i].get() : nullptr;
+                },
+                [&](uint32_t canvasId) -> gpu::RenderCanvas* {
+                    return contentCanvas(canvasId);
+                },
+                [&](uint32_t imageId) -> RenderImage* {
+                    // Resident 2D image, created by the hoisted create pass
+                    // or an earlier frame.
+                    return m_2d.images.get(imageId);
+                });
+            sink.endOreFrame();
+            sink.afterOreFrame();
+        };
+
+        // Creates and mutations replay first over the whole stream in record
+        // order; draws pin the version they recorded against, so the segment
+        // partition below cannot misorder state or break mint order.
+        hooks.filter = ReplayFilter::resources;
+        replayRenderCommands(sink.factory(),
+                             nullptr,
+                             commands,
+                             blobs,
+                             m_2d,
+                             hooks);
+        hooks.filter = ReplayFilter::draws;
+
+        // Partition the 2D segments canvas before screen; the backend allows
+        // one open frame at a time. Ore passes must run inside the screen
+        // frame right after it opens or the ramp upload finds no texture
+        // bound.
+        std::vector<const DeferredSegment*> screenSegments;
+        std::unordered_map<uint64_t, std::vector<const DeferredSegment*>>
+            canvasRanges;
+        for (const DeferredSegment& s : segments)
+        {
+            if (s.target == DeferredSegment::Target::screen)
+            {
+                screenSegments.push_back(&s);
+                continue;
+            }
+            canvasRanges[s.targetId].push_back(&s);
+        }
+        // Canvas groups replay in dependency order, so a sampler sees this
+        // frame's content regardless of record order. Cycles keep record
+        // order on the back edge: previous frame sampling, by contract.
+        CanvasSchedule schedule = scheduleCanvases(commands, segments);
+        if (schedule.hadCycle)
+        {
+            RIVE_WARN_THROTTLED("rive deferred: canvas sample cycle, the "
+                                "back edge samples the previous frame\n");
+        }
+        const std::vector<uint64_t>& canvasOrder = schedule.order;
+        // Ore replays once for the whole session frame, inside the first
+        // screen frame opened. The scripting GPU surface is canvas scoped -
+        // render passes exist only as canvas:beginRenderPass, with no screen
+        // or frame target type - so a script writes canvases and never
+        // screens, and its output reaches a target indirectly through 2D
+        // draws that sample canvas textures. Running it in whichever screen
+        // frame opens first therefore orders it ahead of every target's draws
+        // without attributing it to any one of them. The decision is owned
+        // here, at session frame scope, so per target sinks cannot disagree
+        // about whether Ore ran at all.
+        bool oreReplayed = false;
+        std::unordered_map<uint64_t, Renderer*> openScreens;
+        auto openScreenAndOre = [&](uint64_t target) -> Renderer* {
+            auto entry = openScreens.try_emplace(target, nullptr);
+            if (entry.second)
+            {
+                entry.first->second = sink.beginScreenFrame(target);
+            }
+            if (!oreReplayed)
+            {
+                replayOre();
+                oreReplayed = true;
+            }
+            return entry.first->second;
+        };
+        for (uint64_t canvasId : canvasOrder)
+        {
+            for (const DeferredSegment* seg : canvasRanges[canvasId])
+            {
+                replayRenderCommands(sink.factory(),
+                                     nullptr,
+                                     subSpan(commands, seg->begin, seg->end),
+                                     blobs,
+                                     m_2d,
+                                     hooks);
+            }
+            if (openContentRenderer != nullptr)
+            {
+                sink.endCanvasContent(); // flush once per canvas
+            }
+            openContentRenderer = nullptr;
+            openContentId = kInvalidRenderHandle;
+        }
+        for (const DeferredSegment* seg : screenSegments)
+        {
+            // Target's frame open, Ore inside the first of them, then draw.
+            Renderer* screen = openScreenAndOre(seg->targetId);
+            replayRenderCommands(sink.factory(),
+                                 screen,
+                                 subSpan(commands, seg->begin, seg->end),
+                                 blobs,
+                                 m_2d,
+                                 hooks);
+        }
+        // Work no screen segment claimed still falls to the default target:
+        // ore has nowhere else to run, and a canvas only frame still owes the
+        // host the screen frame its clear and present live in.
+        if (openScreens.empty() &&
+            (!oreCommands.empty() || !canvasRanges.empty()))
+        {
+            openScreenAndOre(sink.defaultScreenTarget());
+        }
+        // Destroys replay last so a destroy recorded in a screen gap cannot
+        // free a resource a reordered canvas segment still draws.
+        hooks.filter = ReplayFilter::destroys;
+        replayRenderCommands(sink.factory(),
+                             nullptr,
+                             commands,
+                             blobs,
+                             m_2d,
+                             hooks);
+    }
+
+    ResourceTable m_2d;          // resident 2D resources
+    ore::cmd::OreResident m_ore; // resident Ore resources
+    ReplayStats m_stats;
+
+public:
+    // Draws dropped in the last replayFrame. Nonzero means mixed factory
+    // recording or a replay bug the host should surface.
+    uint32_t droppedDraws() const { return m_stats.droppedDraws; }
+};
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/deferred_session.hpp b/renderer/include/rive/renderer/cmd/deferred_session.hpp
new file mode 100644
index 0000000..ddf6edb
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/deferred_session.hpp
@@ -0,0 +1,413 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/foreign_image_registry.hpp"
+#include "rive/renderer/cmd/deferred_canvas_host.hpp"
+#include "rive/renderer/cmd/deferred_render_factory.hpp"
+#include "rive/renderer/cmd/render_replay.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_context.hpp"
+#include "rive/renderer/render_canvas.hpp"
+#include <algorithm>
+#include <unordered_map>
+#include <vector>
+
+// DeferredSession owns a deferred frame: the 2D stream (it is a
+// DeferredFactory), the Ore context, and the shared canvas registry.
+// Everything records into one ordered 2D stream, so a single replay pass is
+// byte identical by construction with no command reordering.
+namespace rive::cmd
+{
+
+// One scheduler segment: a canvas or screen run plus the byte range
+// [begin, end) in the 2D stream. Replay runs every canvas segment before any
+// screen segment while preserving record order within each phase.
+struct DeferredSegment
+{
+    enum class Target : uint8_t
+    {
+        canvas, // offscreen, runs first
+        screen, // main target, runs last
+    };
+    Target target;
+    // Canvas id for a canvas run, screen target id for a screen one. A session
+    // serves several screen targets, so a screen run names the one it feeds.
+    uint64_t targetId;
+    uint32_t begin;
+    uint32_t end;
+};
+
+class DeferredSession : public DeferredFactory,
+                        public DeferredCanvasHost,
+                        public DeferredRouteHost
+{
+public:
+    // realOre may be null on web; it late binds via bindRealOre.
+    explicit DeferredSession(ore::Context* realOre) : m_ore(realOre)
+    {
+        // Register wrapped canvases under the shared 2D id space so the
+        // consumer can perform the real wrap at replay.
+        m_ore.canvasIdProvider = [this](gpu::RenderCanvas* canvas) -> uint32_t {
+            RenderHandle id = m_canvases.imageDrawId(canvas->renderImage()) &
+                              kCanvasHandleMask;
+            m_contentCanvases[id] = ref_rcp(canvas);
+            return id;
+        };
+        // The 2D stream shares the ore stream's single writer contract.
+        commandBuffer().bindRecordingThread();
+        // Lets view() on a canvas backed image resolve its canvas id off the
+        // registry.
+        m_ore.canvasRegistry = &m_canvases;
+    }
+
+    ore::cmd::DeferredOreContext& oreContext() { return m_ore; }
+    void bindRealOre(ore::Context* real) { m_ore.bindReal(real); }
+
+    // Cross session image sharing lives here, not in an id space: the
+    // registry carries a real rcp<RenderImage> through the frame snapshot, so
+    // any number of sessions can name the same image without a shared table
+    // to resolve it against, and re-registering per frame keeps the retained
+    // set bounded. Everything else a render context decodes is already shared
+    // because one session records it all.
+    ForeignImageRegistry& canvases() { return m_canvases; }
+
+    // Hosts that import through this session get the recording ore context
+    // automatically.
+    rive::ore::Context* ore() override { return &m_ore; }
+
+    // The context this session records for. Scripts imported through the
+    // session talk to it directly for GPU state while their canvas work
+    // records, so it has to be the real thing, not the session. Web has none
+    // while the file imports, so the host binds the attaching texture's one
+    // here alongside bindRealOre and a script that deferred its canvas backing
+    // reads it again when the real size arrives.
+    void bindRenderContext(Factory* renderContext)
+    {
+        m_renderContext = renderContext;
+    }
+    Factory* renderContext() override { return m_renderContext; }
+    cmd::DeferredCanvasHost* deferredCanvasHost() override { return this; }
+
+    // Routed so a screen draw issued while a canvas range is open lands in a
+    // screen range, not the canvas's.
+    std::unique_ptr<Renderer> makeScreenRenderer(uint64_t target = 0)
+    {
+        return std::make_unique<DeferredRenderer>(&commandBuffer(),
+                                                  &m_canvases,
+                                                  this,
+                                                  screenTarget(target));
+    }
+
+    // Stable screen recorder for FFI hosts that hold a raw pointer across
+    // frames, one per render target this session drives.
+    Renderer* screenRenderer(uint64_t target = 0)
+    {
+        auto& recorder = m_screenRenderers[target];
+        if (recorder == nullptr)
+        {
+            recorder = makeScreenRenderer(target);
+        }
+        return recorder.get();
+    }
+
+    // ---- Render targets ----
+    // A host claims an id for its lifetime; ids are reused so a long lived
+    // context churning textures does not grow the recorder map forever. The
+    // first claim is 0, which is what a single host session records today.
+    uint64_t acquireScreenTarget()
+    {
+        if (!m_freeScreenTargets.empty())
+        {
+            uint64_t id = m_freeScreenTargets.back();
+            m_freeScreenTargets.pop_back();
+            return id;
+        }
+        return m_nextScreenTarget++;
+    }
+    // The host is gone, so its recorder is too. Callers drain first, and a
+    // queued frame holds bytes rather than the recorder.
+    void releaseScreenTarget(uint64_t target)
+    {
+        m_screenRenderers.erase(target);
+        m_freeScreenTargets.push_back(target);
+    }
+    // Hosts holding an id right now. Sizes anything that has to scale with the
+    // targets sharing this session, such as the consumer's queue bound.
+    size_t attachedTargetCount() const
+    {
+        return static_cast<size_t>(m_nextScreenTarget) -
+               m_freeScreenTargets.size();
+    }
+
+    // ---- Frame boundary ----
+    // A session serves every target its render context drives, so the frame
+    // is a session-wide window: it opens when the first target starts
+    // recording and closes when the last one finishes. Ending the window per
+    // host would reset the stream underneath a target still recording.
+    void beginTargetFrame(uint64_t target)
+    {
+        if (std::find(m_openTargets.begin(), m_openTargets.end(), target) ==
+            m_openTargets.end())
+        {
+            m_openTargets.push_back(target);
+        }
+    }
+    // True once this closes the last open target, meaning the caller may take
+    // the frame. A target that never opened one closes nothing.
+    bool endTargetFrame(uint64_t target)
+    {
+        auto it = std::find(m_openTargets.begin(), m_openTargets.end(), target);
+        if (it == m_openTargets.end())
+        {
+            return m_openTargets.empty();
+        }
+        m_openTargets.erase(it);
+        return m_openTargets.empty();
+    }
+    // A host that stops recording without finishing, such as one paused
+    // mid frame, must not pin the window shut for everyone else.
+    void abandonTargetFrame(uint64_t target)
+    {
+        auto it = std::find(m_openTargets.begin(), m_openTargets.end(), target);
+        if (it != m_openTargets.end())
+        {
+            m_openTargets.erase(it);
+        }
+    }
+
+    // ---- DeferredRouteHost ----
+    // Splits the stream into per target scheduler ranges as the issuing
+    // renderer changes.
+    void routeTo(uint64_t target) override
+    {
+        if (m_activeRouted && target == m_activeTarget)
+        {
+            return;
+        }
+        closeActiveRange();
+        m_activeTarget = target;
+        m_activeRouted = true;
+        m_activeBegin = streamSize();
+        if (isScreenTarget(target))
+        {
+            m_openScreen = screenTargetId(target);
+            m_hasOpenScreen = true;
+            return;
+        }
+        RenderHandle id = static_cast<RenderHandle>(target);
+        commandBuffer().append(
+            static_cast<uint8_t>(RenderCmd::canvasContentBegin),
+            CanvasContentPOD{id | kCanvasHandleFlag, m_canvasClear[target]});
+    }
+
+    // Snapshots call this too since an errored script can leave a range open.
+    void closeOpenRange()
+    {
+        closeActiveRange();
+        reopenUnroutedRange();
+    }
+
+    // ---- DeferredCanvasHost ----
+    // A canvas may record as several interleaved ranges; replay groups them
+    // back into one real canvas frame.
+    Renderer* beginCanvasContent(gpu::RenderCanvas* canvas,
+                                 uint32_t clearColor) override
+    {
+        RenderHandle id =
+            m_canvases.imageDrawId(canvas->renderImage()) & kCanvasHandleMask;
+        m_contentCanvases[id] = ref_rcp(canvas);
+        m_canvasClear[id] = clearColor;
+        auto& recorder = m_canvasRenderers[id];
+        if (recorder == nullptr)
+        {
+            recorder = std::make_unique<DeferredRenderer>(&commandBuffer(),
+                                                          &m_canvases,
+                                                          this,
+                                                          id);
+        }
+        // Open the range now so a clear-only frame still clears at replay.
+        routeTo(id);
+        return recorder.get();
+    }
+    void endCanvasContent(gpu::RenderCanvas*) override
+    {
+        // Back to the screen whose recording the canvas interrupted, so the
+        // bytes that follow are not credited to a target that drew nothing.
+        if (m_hasOpenScreen)
+        {
+            routeTo(screenTarget(m_openScreen));
+            return;
+        }
+        closeActiveRange();
+        reopenUnroutedRange();
+    }
+
+    // Replay bindings are per frame so the retained set stays bounded; each
+    // frame's draws re-register what they reference.
+    void resetFrame()
+    {
+        DeferredFactory::resetFrame();
+        m_ore.resetFrame();
+        m_canvases.reset();
+        m_contentCanvases.clear();
+        m_canvasRenderers.clear();
+        m_canvasClear.clear();
+        m_activeTarget = kScreenTarget;
+        m_activeRouted = false;
+        m_activeBegin = 0;
+        m_openScreen = 0;
+        m_hasOpenScreen = false;
+        m_hasOreMarker = false;
+        m_segments.clear();
+    }
+
+    // Physical bytes this session's producer streams hold. Computed on
+    // demand so recording pays nothing; the frame boundary drains it, so it
+    // only means anything read before a snapshot.
+    uint64_t streamBytes() const
+    {
+        return commandBuffer().commandBytes().size() +
+               commandBuffer().blobBytes().size() +
+               m_ore.stream().commandBytes().size() +
+               m_ore.stream().blobBytes().size();
+    }
+
+    // Nothing recorded: the host keeps its last presented frame up. Pending
+    // ore content counts, since one shot content like a canvas wrap never
+    // re-records and must not park behind the gate.
+    bool recordedThisFrame() const
+    {
+        return m_hasOreMarker || !commandBuffer().empty() ||
+               !m_ore.stream().empty();
+    }
+
+    // Closed segments in record order, canvas and screen alike.
+    const std::vector<DeferredSegment>& recordedSegments() const
+    {
+        return m_segments;
+    }
+    // Full scheduler input: the closed segments plus the range still open.
+    std::vector<DeferredSegment> schedulerSegments() const
+    {
+        std::vector<DeferredSegment> all = m_segments;
+        if (m_activeRouted && isScreenTarget(m_activeTarget) &&
+            streamSize() > m_activeBegin)
+        {
+            all.push_back({DeferredSegment::Target::screen,
+                           screenTargetId(m_activeTarget),
+                           m_activeBegin,
+                           streamSize()});
+        }
+        return all;
+    }
+
+    // Ore replays via segment scheduling; the frame only needs to know Ore
+    // content exists so an otherwise empty frame still replays.
+    void recordOreReplayMarker() { m_hasOreMarker = true; }
+
+    // Render thread lookups for the replay hooks.
+    gpu::RenderCanvas* contentCanvasAt(RenderHandle id) const
+    {
+        auto it = m_contentCanvases.find(id);
+        return it == m_contentCanvases.end() ? nullptr : it->second.get();
+    }
+    RenderImage* canvasImageAt(RenderHandle id) const
+    {
+        return m_canvases.imageAt(id);
+    }
+    // Retained content canvas bindings for the consumer snapshot.
+    const std::unordered_map<RenderHandle, rcp<gpu::RenderCanvas>>&
+    contentCanvases() const
+    {
+        return m_contentCanvases;
+    }
+
+private:
+    uint32_t streamSize() const
+    {
+        return static_cast<uint32_t>(commandBuffer().commandBytes().size());
+    }
+
+    // Reopen the range no target has claimed. Bytes appended outside any
+    // renderer - resource creates, drained destroys - belong to no target:
+    // they replay from the whole stream in the create and destroy passes, so
+    // crediting them to a screen would open that target's frame in a frame
+    // where only other targets drew.
+    void reopenUnroutedRange()
+    {
+        m_activeTarget = screenTarget(m_openScreen);
+        m_activeRouted = m_hasOpenScreen;
+        m_activeBegin = streamSize();
+    }
+
+    // Push the open range as a segment, closing a canvas one's bracket first.
+    // An empty screen range is dropped: replaying it would open its target's
+    // frame to draw nothing.
+    void closeActiveRange()
+    {
+        if (!m_activeRouted)
+        {
+            return;
+        }
+        if (isScreenTarget(m_activeTarget))
+        {
+            if (streamSize() > m_activeBegin)
+            {
+                m_segments.push_back({DeferredSegment::Target::screen,
+                                      screenTargetId(m_activeTarget),
+                                      m_activeBegin,
+                                      streamSize()});
+            }
+            return;
+        }
+        RenderHandle id = static_cast<RenderHandle>(m_activeTarget);
+        commandBuffer().append(
+            static_cast<uint8_t>(RenderCmd::canvasContentEnd),
+            ResIdPOD{id | kCanvasHandleFlag});
+        // Push order of m_segments defines record order across both streams.
+        m_segments.push_back({DeferredSegment::Target::canvas,
+                              m_activeTarget,
+                              m_activeBegin,
+                              streamSize()});
+    }
+
+    ore::cmd::DeferredOreContext m_ore;
+    // Set on texture attach, read by the producer's scripts. Written and read
+    // on different threads in the worker build, exactly as m_ore's real
+    // binding already is.
+    Factory* m_renderContext = nullptr;
+    ForeignImageRegistry m_canvases;
+    // Canvas id to real canvas, retained so it lives to replay. Per frame.
+    std::unordered_map<RenderHandle, rcp<gpu::RenderCanvas>> m_contentCanvases;
+    // Alive until resetFrame so scripted renderers stay valid across
+    // interleaved canvas frames.
+    std::unordered_map<uint64_t, std::unique_ptr<DeferredRenderer>>
+        m_canvasRenderers;
+    // First beginFrame's clear color; replay clears only when it opens the
+    // real frame.
+    std::unordered_map<uint64_t, uint32_t> m_canvasClear;
+    // Deliberately outlives resetFrame, unlike m_canvasRenderers: FFI hosts
+    // take these raw and keep drawing through them frame after frame.
+    std::unordered_map<uint64_t, std::unique_ptr<Renderer>> m_screenRenderers;
+    bool m_hasOreMarker = false;
+    // Scheduler segments recorded this frame, in script issue order.
+    std::vector<DeferredSegment> m_segments;
+    uint64_t m_activeTarget = kScreenTarget; // target of the open range
+    // False while the open range belongs to no target, which is how a frame
+    // starts and where creates outside any renderer land.
+    bool m_activeRouted = false;
+    uint32_t m_activeBegin = 0; // open range's start offset
+    // Screen target a closing canvas range hands the stream back to, unset
+    // until a screen actually records.
+    uint64_t m_openScreen = 0;
+    bool m_hasOpenScreen = false;
+    // Render targets attached to this session, and the ones still recording
+    // this frame.
+    uint64_t m_nextScreenTarget = 0;
+    std::vector<uint64_t> m_freeScreenTargets;
+    std::vector<uint64_t> m_openTargets;
+};
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/foreign_image_registry.hpp b/renderer/include/rive/renderer/cmd/foreign_image_registry.hpp
new file mode 100644
index 0000000..75ca018
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/foreign_image_registry.hpp
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer.hpp"
+#include "rive/renderer/cmd/render_handle.hpp"
+#include <cassert>
+#include <unordered_map>
+#include <vector>
+
+// Foreign image registry: any RenderImage a deferred drawImage references that
+// is not a decoded DeferredRenderImage gets a flagged id here on first sight,
+// so the stream carries an id, not a pointer. Entries are retained so they
+// live to replay; the registry is per frame, keeping the set bounded.
+namespace rive::cmd
+{
+
+class ForeignImageRegistry
+{
+public:
+    // The flagged draw id for a foreign image; registers it on first sight.
+    RenderHandle imageDrawId(RenderImage* image)
+    {
+        auto it = m_imageToId.find(image);
+        RenderHandle id;
+        if (it != m_imageToId.end())
+        {
+            id = it->second;
+        }
+        else
+        {
+            id = static_cast<RenderHandle>(m_images.size());
+            // The unflagged id must fit under the flag bit.
+            assert(id <= kCanvasHandleMask);
+            m_images.push_back(ref_rcp(image));
+            m_imageToId[image] = id;
+        }
+        return kCanvasHandleFlag | id;
+    }
+
+    // Replay time lookup of the real image by unflagged id.
+    RenderImage* imageAt(RenderHandle id) const
+    {
+        return id < m_images.size() ? m_images[id].get() : nullptr;
+    }
+
+    // Retained id indexed images for the consumer snapshot.
+    const std::vector<rcp<RenderImage>>& images() const { return m_images; }
+
+    void reset()
+    {
+        m_images.clear();
+        m_imageToId.clear();
+    }
+
+private:
+    std::vector<rcp<RenderImage>> m_images;
+    std::unordered_map<RenderImage*, RenderHandle> m_imageToId;
+};
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/gpu_census.hpp b/renderer/include/rive/renderer/cmd/gpu_census.hpp
new file mode 100644
index 0000000..239bba0
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/gpu_census.hpp
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/render_replay.hpp"
+#include "rive/renderer/ore/cmd/ore_make_replay.hpp"
+#include "rive/renderer/ore/ore_buffer.hpp"
+#include "rive/renderer/ore/ore_texture.hpp"
+#include "rive/renderer/ore/ore_types.hpp"
+
+// What deferred replay is holding resident on the GPU side, walked out of the
+// resident tables on demand.
+//
+// This is a levels counter, not an events counter: nothing is accumulated
+// while recording or replaying, so the record and replay paths pay nothing at
+// all for it and it needs no build flag. The cost is one linear walk of the
+// resident tables, at the moment a caller asks. Callers must walk while the
+// tables are quiescent - see DeferredConsumer::gpuCensus, which drains first.
+namespace rive::cmd
+{
+
+// Bytes are the nominal footprint of the resource as declared: texels times
+// bytes per texel, buffer sizes as requested. Driver padding, alignment and
+// any backend side scratch are not visible from here and are excluded, so a
+// total is a floor on real GPU residency. It is the right shape for asking
+// whether two arrangements hold the same resources, which is what it is for.
+struct GpuCensus
+{
+    // 2D resources.
+    uint64_t imageBytes = 0;  // RenderImage, assumed 4 bytes per texel
+    uint64_t bufferBytes = 0; // RenderBuffer, exact
+    // Ore (scripting GPU) resources.
+    uint64_t oreTextureBytes = 0; // ore::Texture, exact for uncompressed
+    uint64_t oreBufferBytes = 0;  // ore::Buffer, exact
+
+    // Live objects per table, so table shape is visible next to the bytes.
+    // Paths, paints and shaders carry GPU cost that is not a declared
+    // allocation (tessellation, gradient ramps), so they are counted but not
+    // sized.
+    uint32_t images = 0;
+    uint32_t buffers = 0;
+    uint32_t paths = 0;
+    uint32_t paints = 0;
+    uint32_t shaders = 0;
+    uint32_t oreTextures = 0;
+    uint32_t oreBuffers = 0;
+    uint32_t oreOther = 0; // views, samplers, pipelines, bind groups
+
+    // Slots ever minted, live or freed. slots - live is the hole count the
+    // never-compacting tables carry.
+    uint32_t slots2d = 0;
+    uint32_t slotsOre = 0;
+
+    uint64_t totalBytes() const
+    {
+        return imageBytes + bufferBytes + oreTextureBytes + oreBufferBytes;
+    }
+
+    uint32_t liveObjects() const
+    {
+        return images + buffers + paths + paints + shaders + oreTextures +
+               oreBuffers + oreOther;
+    }
+};
+
+// Texels across every mip level, array layer and MSAA sample. Returns 0 for a
+// block compressed format rather than guessing a block size.
+inline uint64_t oreTextureNominalBytes(const ore::Texture& t)
+{
+    uint32_t bpt = ore::textureFormatBytesPerTexel(t.format());
+    if (bpt == 0)
+    {
+        return 0;
+    }
+    uint64_t texels = 0;
+    uint32_t w = t.width(), h = t.height();
+    // numMipmaps counts the full chain including level 0.
+    for (uint32_t level = 0; level < std::max<uint32_t>(t.numMipmaps(), 1);
+         ++level)
+    {
+        texels += uint64_t(w) * h;
+        if (w == 1 && h == 1)
+        {
+            break;
+        }
+        w = std::max<uint32_t>(w >> 1, 1);
+        h = std::max<uint32_t>(h >> 1, 1);
+    }
+    return texels * std::max<uint32_t>(t.depthOrArrayLayers(), 1) *
+           std::max<uint32_t>(t.sampleCount(), 1) * bpt;
+}
+
+template <typename T>
+static uint32_t countLive(const Resident<T>& r, uint32_t& slots)
+{
+    slots += static_cast<uint32_t>(r.objects.size());
+    uint32_t live = 0;
+    for (const rcp<T>& o : r.objects)
+    {
+        live += (o != nullptr);
+    }
+    return live;
+}
+
+inline GpuCensus takeGpuCensus(const ResourceTable& t2d,
+                               const ore::cmd::OreResident& ore)
+{
+    GpuCensus c;
+    c.paths = countLive(t2d.paths, c.slots2d);
+    c.paints = countLive(t2d.paints, c.slots2d);
+    c.shaders = countLive(t2d.shaders, c.slots2d);
+    c.buffers = countLive(t2d.buffers, c.slots2d);
+    c.images = countLive(t2d.images, c.slots2d);
+
+    for (const rcp<RenderImage>& img : t2d.images.objects)
+    {
+        if (img != nullptr)
+        {
+            // No format on the 2D interface; every backend path here is
+            // 32 bit color.
+            c.imageBytes += uint64_t(img->width()) * img->height() * 4;
+        }
+    }
+    for (const rcp<RenderBuffer>& buf : t2d.buffers.objects)
+    {
+        if (buf != nullptr)
+        {
+            c.bufferBytes += buf->sizeInBytes();
+        }
+    }
+
+    c.slotsOre = static_cast<uint32_t>(ore.objects.size());
+    for (size_t i = 0; i < ore.objects.size(); ++i)
+    {
+        rive::gpu::GPUResource* o = ore.objects[i].get();
+        if (o == nullptr)
+        {
+            continue;
+        }
+        switch (ore.kinds[i])
+        {
+            case ore::cmd::OreKind::texture:
+                c.oreTextures++;
+                c.oreTextureBytes +=
+                    oreTextureNominalBytes(*static_cast<ore::Texture*>(o));
+                break;
+            case ore::cmd::OreKind::buffer:
+                c.oreBuffers++;
+                c.oreBufferBytes += static_cast<ore::Buffer*>(o)->size();
+                break;
+            default:
+                c.oreOther++;
+                break;
+        }
+    }
+    return c;
+}
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/handle_flags.hpp b/renderer/include/rive/renderer/cmd/handle_flags.hpp
new file mode 100644
index 0000000..9bca7e6
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/handle_flags.hpp
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include <cstdint>
+
+// Handle bit partition shared by the 2D and Ore streams: the top bit flags a
+// foreign table (canvas image or already real resource), and minted ids stay
+// below it. The id allocator enforces that.
+namespace rive::cmd
+{
+constexpr uint32_t kHandleForeignFlag = 0x80000000u;
+constexpr uint32_t kHandleForeignMask = 0x7fffffffu;
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/id_allocator.hpp b/renderer/include/rive/renderer/cmd/id_allocator.hpp
new file mode 100644
index 0000000..cf914b5
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/id_allocator.hpp
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/handle_flags.hpp"
+#include <cassert>
+#include <cstddef>
+#include <cstdint>
+#include <vector>
+
+// Dense reusable id allocation for the deferred resource tables. A free list
+// carries each returned id's next generation, so stale commands are caught by
+// generation mismatch on the consumer. An id retires when its generation would
+// overflow. Lives in rive, not cmd, shared by the 2D and Ore layers.
+namespace rive
+{
+
+template <typename Id> class IdAllocator
+{
+public:
+    struct Allocation
+    {
+        Id id;
+        uint32_t generation;
+    };
+
+    // Reuse a returned id (generation already bumped) or mint a fresh one at 0.
+    Allocation alloc()
+    {
+        if (!m_free.empty())
+        {
+            Allocation a = m_free.back();
+            m_free.pop_back();
+            return a;
+        }
+        // The top bit is reserved for flagged foreign ids, so a minted id
+        // must never reach it.
+        assert(m_next < cmd::kHandleForeignFlag);
+        return {static_cast<Id>(m_next++), 0u};
+    }
+
+    // The caller passes the generation it held, so the allocator needs no per
+    // id state. Retire the id if the next generation would overflow.
+    void release(Id id, uint32_t generation)
+    {
+        if (generation != 0xffffffffu)
+        {
+            m_free.push_back({id, generation + 1u});
+        }
+    }
+
+private:
+    std::vector<Allocation> m_free;
+    uint32_t m_next = 0; // high-water for fresh ids
+};
+
+} // namespace rive
diff --git a/renderer/include/rive/renderer/cmd/live_recorder_registry.hpp b/renderer/include/rive/renderer/cmd/live_recorder_registry.hpp
new file mode 100644
index 0000000..d832191
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/live_recorder_registry.hpp
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include <mutex>
+#include <unordered_set>
+
+// A recording context can die before stragglers recorded against it. The live
+// set lets late destructors no-op instead of writing into a freed recorder;
+// the mutex serializes them against teardown.
+namespace rive::cmd
+{
+
+inline std::mutex& recorderRegistryMutex()
+{
+    static std::mutex m;
+    return m;
+}
+inline std::unordered_set<const void*>& liveRecorders()
+{
+    static std::unordered_set<const void*> s;
+    return s;
+}
+inline void registerRecorder(const void* recorder)
+{
+    std::lock_guard<std::mutex> lock(recorderRegistryMutex());
+    liveRecorders().insert(recorder);
+}
+inline void unregisterRecorder(const void* recorder)
+{
+    std::lock_guard<std::mutex> lock(recorderRegistryMutex());
+    liveRecorders().erase(recorder);
+}
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/recording_thread.hpp b/renderer/include/rive/renderer/cmd/recording_thread.hpp
new file mode 100644
index 0000000..e24a3f2
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/recording_thread.hpp
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include <cassert>
+#ifndef NDEBUG
+#include <thread>
+#endif
+
+// Debug only thread affinity for the deferred producer streams.
+//
+// Recording is single threaded by construction: every widget records
+// synchronously from the Dart UI isolate. The producer state that assumption
+// buys -- the command byte vectors, the id free list -- carries no mutex and
+// no atomic, so a second recorder appending concurrently would corrupt them
+// with no diagnostic at all. This makes that violation loud.
+//
+// The off thread work the deferred design does allow never touches producer
+// state directly, and must not be checked: cross thread destroys queue under
+// OreCommandBuffer's destroy mutex, late destructors serialize on
+// recorderRegistryMutex, the producer/consumer handoff has DeferredInFlight's,
+// and session teardown drains the destroy queue from whichever thread the host
+// posted the delete to.
+namespace rive::cmd
+{
+
+// Unbound until a recorder claims it, so a command buffer that is not a
+// deferred producer -- an inline pass buffer, a backend's pending frame --
+// keeps its existing freedom to live wherever its owner does.
+class RecordingThread
+{
+public:
+#ifdef NDEBUG
+    void bind() {}
+    void check() const {}
+#else
+    void bind() { m_id = std::this_thread::get_id(); }
+
+    void check() const
+    {
+        assert(
+            (m_id == std::thread::id() || std::this_thread::get_id() == m_id) &&
+            "deferred recording is single threaded: this stream, its id "
+            "allocator, and its keep alive table are all unlocked");
+    }
+
+private:
+    std::thread::id m_id;
+#endif
+};
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/render_command_buffer.hpp b/renderer/include/rive/renderer/cmd/render_command_buffer.hpp
new file mode 100644
index 0000000..61921ad
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/render_command_buffer.hpp
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/span.hpp"
+#include "rive/renderer/cmd/command_stream.hpp"
+#include "rive/renderer/cmd/id_allocator.hpp"
+#include "rive/renderer/cmd/recording_thread.hpp"
+#include "rive/renderer/cmd/render_commands.hpp"
+#include <cassert>
+#include <cstdint>
+#include <mutex>
+#include <vector>
+
+// RenderCommandBuffer is a flat pointer free byte stream plus a blob arena.
+// Everything is referenced by dense resource id, never a pointer, so the bytes
+// cross a SharedArrayBuffer to a worker zero copy. It holds no live resources;
+// the render side recreates everything from the stream by id.
+namespace rive::cmd
+{
+
+class RenderCommandBuffer : public CommandByteStream
+{
+public:
+    // A session's producer binds; a backend owned buffer leaves it unbound.
+    // Debug only, see RecordingThread.
+    void bindRecordingThread() { m_recordingThread.bind(); }
+
+    // Append [type byte][POD]. POD must be trivially copyable.
+    template <typename POD> void append(uint8_t type, const POD& pod)
+    {
+        static_assert(std::is_trivially_copyable<POD>::value,
+                      "command POD must be trivially copyable");
+        m_recordingThread.check();
+        writeRaw(&type, sizeof(type));
+        writeRaw(&pod, sizeof(pod));
+    }
+
+    // Append just a type byte (commands with no payload).
+    void appendType(uint8_t type)
+    {
+        m_recordingThread.check();
+        writeRaw(&type, sizeof(type));
+    }
+
+    // GC finalizers destroy on worker threads, so destroys queue and drain on
+    // the recording thread.
+    struct PendingDestroy
+    {
+        uint8_t kind;
+        RenderHandle id;
+        uint32_t generation;
+        IdAllocator<RenderHandle>* allocator;
+    };
+
+    void queueDestroy(uint8_t kind,
+                      RenderHandle id,
+                      uint32_t generation,
+                      IdAllocator<RenderHandle>* allocator)
+    {
+        std::lock_guard<std::mutex> lock(m_destroyMutex);
+        m_pendingDestroys.push_back({kind, id, generation, allocator});
+    }
+
+    // The id goes straight back, so the next create in this same stream may
+    // retake it. Safe because a consumer replays whole frames in stream order
+    // and every resident slot is generation checked: the retake's create
+    // stamps a new generation, so this destroy no-ops when it replays and a
+    // snapshot recorded before it still resolves its own generation out of its
+    // own byte copy.
+    void drainDestroys()
+    {
+        std::vector<PendingDestroy> pending;
+        {
+            std::lock_guard<std::mutex> lock(m_destroyMutex);
+            pending.swap(m_pendingDestroys);
+        }
+        for (const auto& p : pending)
+        {
+            append(static_cast<uint8_t>(RenderCmd::destroyResource),
+                   DestroyResourcePOD{p.kind, p.id, p.generation});
+            if (p.allocator != nullptr)
+            {
+                p.allocator->release(p.id, p.generation);
+            }
+        }
+    }
+
+    void reset()
+    {
+        clearBytes();
+        m_frameId++;
+    }
+
+    // Which frame is recording. Resources stamp draws with it so only a
+    // mutation of a resource drawn THIS frame bumps a version; the first
+    // mutation of a new frame reuses the live replay object in place.
+    uint32_t frameId() const { return m_frameId; }
+
+private:
+    RecordingThread m_recordingThread;
+    std::mutex m_destroyMutex;
+    std::vector<PendingDestroy> m_pendingDestroys;
+    uint32_t m_frameId = 0;
+};
+
+using RenderCommandReader = CommandReader<uint8_t>;
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/render_commands.hpp b/renderer/include/rive/renderer/cmd/render_commands.hpp
new file mode 100644
index 0000000..1c778ff
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/render_commands.hpp
@@ -0,0 +1,350 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/render_handle.hpp"
+#include <cstddef>
+#include <cstdint>
+
+// The 2D command vocabulary recorded into a RenderCommandBuffer. One
+// interleaved stream in record order; replay walks it once, so a resource is
+// always created and configured before use. All POD, no pointers.
+namespace rive::cmd
+{
+
+enum class RenderCmd : uint8_t
+{
+    // ---- resource creation ----
+    // Each make* carries an explicit id and generation so ids can be reused
+    // without the consumer table growing unboundedly.
+    makePath,           // MakePathPOD (+ rawpath blob)
+    makeEmptyPath,      // MakeIdPOD
+    makePaint,          // MakeIdPOD
+    makeLinearGradient, // LinearGradientPOD (+ colors[]+stops[] blob)
+    makeRadialGradient, // RadialGradientPOD (+ colors[]+stops[] blob)
+    decodeImage,        // DecodeImagePOD (+ encoded-bytes blob)
+    makeBuffer,         // MakeBufferPOD
+    bufferData,         // BufferDataPOD (+ data blob), a map()/unmap() write
+
+    // The consumer releases table[id] only when the generation matches, so a
+    // stale destroy after slot reuse is harmless.
+    destroyResource, // DestroyResourcePOD (kind, id, generation)
+
+    // ---- path mutations ----
+    // Per verb builder calls accumulate into a scratch RawPath flushed as
+    // pathAddRawPath, so there are no per verb commands in the stream.
+    pathRewind,        // ResId
+    pathFillRule,      // PathFillRulePOD
+    pathAddRawPath,    // PathRawPOD (+ rawpath blob)
+    pathAddRenderPath, // PathAddPathPOD
+
+    // ---- paint mutations ----
+    paintStyle,            // PaintU8POD
+    paintColor,            // PaintColorPOD
+    paintThickness,        // PaintFloatPOD
+    paintJoin,             // PaintU8POD
+    paintCap,              // PaintU8POD
+    paintFeather,          // PaintFloatPOD
+    paintBlendMode,        // PaintU8POD
+    paintShader,           // PaintShaderPOD
+    paintInvalidateStroke, // ResId
+
+    // ---- renderer draws ----
+    save,            // no payload
+    restore,         // no payload
+    transform,       // TransformPOD
+    drawPath,        // DrawPathPOD
+    clipPath,        // ClipPathPOD
+    drawImage,       // DrawImagePOD
+    drawImageMesh,   // DrawImageMeshPOD
+    modulateOpacity, // OpacityPOD
+
+    // ---- render target scheduling ----
+    // Canvas content records inline between these brackets; replay redirects
+    // it into the canvas's own frame since PLS frames cannot nest.
+    canvasContentBegin, // CanvasContentPOD (canvas id, clear color)
+    canvasContentEnd,   // ResId (canvas id)
+                        // draws that sample it. Opens the screen frame.
+
+    // A drawn resource was mutated again this frame: draws pin the version
+    // they saw, replay materializes the outgoing version before applying
+    // later mutations.
+    resourceNewVersion, // ResourceVersionPOD
+
+    // Keep equal to the last real opcode so replay rejects corrupt type bytes
+    // instead of desyncing.
+    lastRenderCmd = resourceNewVersion,
+};
+
+// A bare resource id payload.
+struct ResIdPOD
+{
+    RenderHandle id;
+};
+
+// Which id space a destroyed resource belongs to; an id alone is ambiguous.
+enum class ResourceKind : uint8_t
+{
+    path,
+    paint,
+    shader,
+    image,
+    buffer,
+};
+
+struct DestroyResourcePOD
+{
+    uint8_t kind; // ResourceKind
+    RenderHandle id;
+    uint32_t generation;
+};
+
+struct ResourceVersionPOD
+{
+    uint8_t kind; // ResourceKind
+    RenderHandle id;
+    uint32_t version;
+};
+
+// make* that carries only its id.
+struct MakeIdPOD
+{
+    RenderHandle id;
+    uint32_t generation;
+};
+
+// Wire PODs stream raw through writeRaw, so layouts carry explicit pad
+// fields wherever the 8-aligned 64 bit offsets would otherwise introduce
+// implicit (uninitialized) padding.
+struct MakePathPOD
+{
+    RenderHandle id;
+    uint32_t generation;
+    uint64_t blobOffset;   // verbs
+    uint64_t pointsOffset; // points (own blob, 8-aligned)
+    uint32_t verbCount;
+    uint32_t pointCount;
+    uint32_t fillRule;
+    uint32_t pad;
+};
+static_assert(sizeof(MakePathPOD) == 10 * sizeof(uint32_t),
+              "wire POD must be pointer-free and padding-free");
+
+struct LinearGradientPOD
+{
+    RenderHandle id;
+    uint32_t generation;
+    float sx, sy, ex, ey;
+    uint64_t blobOffset;  // colors
+    uint64_t stopsOffset; // stops (own blob, 8-aligned)
+    uint32_t count;
+    uint32_t pad;
+};
+static_assert(sizeof(LinearGradientPOD) == 12 * sizeof(uint32_t),
+              "wire POD must be pointer-free and padding-free");
+
+struct RadialGradientPOD
+{
+    RenderHandle id;
+    uint32_t generation;
+    float cx, cy, radius;
+    uint32_t count;
+    uint64_t blobOffset;  // colors
+    uint64_t stopsOffset; // stops (own blob, 8-aligned)
+};
+static_assert(sizeof(RadialGradientPOD) == 10 * sizeof(uint32_t),
+              "wire POD must be pointer-free and padding-free");
+
+struct PathFillRulePOD
+{
+    RenderHandle path;
+    uint8_t fillRule;
+};
+
+struct PathRawPOD
+{
+    uint64_t blobOffset;   // verbs
+    uint64_t pointsOffset; // points (own blob, 8-aligned)
+    RenderHandle path;
+    uint32_t verbCount;
+    uint32_t pointCount;
+    uint32_t pad;
+};
+static_assert(sizeof(PathRawPOD) == 8 * sizeof(uint32_t),
+              "wire POD must be pointer-free and padding-free");
+
+struct PathAddPathPOD
+{
+    RenderHandle path;
+    RenderHandle src;
+    float xx, xy, yx, yy, tx, ty; // Mat2D
+};
+
+struct PaintU8POD
+{
+    RenderHandle paint;
+    uint8_t value;
+};
+
+struct PaintColorPOD
+{
+    RenderHandle paint;
+    uint32_t color;
+};
+
+struct PaintFloatPOD
+{
+    RenderHandle paint;
+    float value;
+};
+
+struct PaintShaderPOD
+{
+    RenderHandle paint;
+    RenderHandle shader; // kInvalidRenderHandle clears the shader
+};
+
+struct TransformPOD
+{
+    float xx, xy, yx, yy, tx, ty; // Mat2D
+};
+
+struct DrawPathPOD
+{
+    RenderHandle path;
+    RenderHandle paint;
+    uint32_t pathVersion;
+    uint32_t paintVersion;
+};
+
+struct ClipPathPOD
+{
+    RenderHandle path;
+    uint32_t version;
+};
+
+struct DecodeImagePOD
+{
+    RenderHandle id;
+    uint32_t generation;
+    uint64_t blobOffset;
+    uint32_t byteCount;
+    uint32_t width;
+    uint32_t height;
+    uint32_t pad;
+};
+static_assert(sizeof(DecodeImagePOD) == 8 * sizeof(uint32_t),
+              "wire POD must be pointer-free and padding-free");
+
+struct MakeBufferPOD
+{
+    RenderHandle id;
+    uint32_t generation;
+    uint8_t bufferType; // RenderBufferType
+    uint8_t flags;      // RenderBufferFlags
+    uint32_t sizeInBytes;
+};
+
+struct BufferDataPOD
+{
+    uint64_t blobOffset;
+    RenderHandle buffer;
+    uint32_t size;
+};
+static_assert(sizeof(BufferDataPOD) == 4 * sizeof(uint32_t),
+              "wire POD must be pointer-free and padding-free");
+
+struct DrawImagePOD
+{
+    RenderHandle image;
+    uint8_t wrapX, wrapY, filter; // ImageSampler
+    uint8_t blendMode;
+    float opacity;
+};
+
+struct DrawImageMeshPOD
+{
+    RenderHandle image;
+    RenderHandle vertices, uvCoords, indices;
+    uint32_t vertexVersion, uvVersion, indexVersion;
+    uint32_t vertexCount, indexCount;
+    uint8_t wrapX, wrapY, filter; // ImageSampler
+    uint8_t blendMode;
+    float opacity;
+};
+
+struct OpacityPOD
+{
+    float opacity;
+};
+
+struct CanvasContentPOD
+{
+    RenderHandle
+        canvasId; // flagged canvas id (kCanvasHandleFlag), as in drawImage
+    uint32_t clearColor; // ARGB; the canvas frame's clear (script-controlled)
+};
+
+// Opcode to payload table, one X(opcode, POD) per command; void means no
+// payload. Every size a skip or filter walk uses derives from here, so a new
+// command cannot desync them. Blobs ride separately and never affect sizes.
+#define RIVE_RENDER_CMD_TABLE(X)                                               \
+    X(makePath, MakePathPOD)                                                   \
+    X(makeEmptyPath, MakeIdPOD)                                                \
+    X(makePaint, MakeIdPOD)                                                    \
+    X(makeLinearGradient, LinearGradientPOD)                                   \
+    X(makeRadialGradient, RadialGradientPOD)                                   \
+    X(decodeImage, DecodeImagePOD)                                             \
+    X(makeBuffer, MakeBufferPOD)                                               \
+    X(bufferData, BufferDataPOD)                                               \
+    X(destroyResource, DestroyResourcePOD)                                     \
+    X(pathRewind, ResIdPOD)                                                    \
+    X(pathFillRule, PathFillRulePOD)                                           \
+    X(pathAddRawPath, PathRawPOD)                                              \
+    X(pathAddRenderPath, PathAddPathPOD)                                       \
+    X(paintStyle, PaintU8POD)                                                  \
+    X(paintColor, PaintColorPOD)                                               \
+    X(paintThickness, PaintFloatPOD)                                           \
+    X(paintJoin, PaintU8POD)                                                   \
+    X(paintCap, PaintU8POD)                                                    \
+    X(paintFeather, PaintFloatPOD)                                             \
+    X(paintBlendMode, PaintU8POD)                                              \
+    X(paintShader, PaintShaderPOD)                                             \
+    X(paintInvalidateStroke, ResIdPOD)                                         \
+    X(save, void)                                                              \
+    X(restore, void)                                                           \
+    X(transform, TransformPOD)                                                 \
+    X(drawPath, DrawPathPOD)                                                   \
+    X(clipPath, ClipPathPOD)                                                   \
+    X(drawImage, DrawImagePOD)                                                 \
+    X(drawImageMesh, DrawImageMeshPOD)                                         \
+    X(modulateOpacity, OpacityPOD)                                             \
+    X(canvasContentBegin, CanvasContentPOD)                                    \
+    X(canvasContentEnd, ResIdPOD)                                              \
+    X(resourceNewVersion, ResourceVersionPOD)
+
+namespace detail
+{
+template <typename POD> constexpr size_t payloadSizeOfPOD()
+{
+    return sizeof(POD);
+}
+template <> constexpr size_t payloadSizeOfPOD<void>() { return 0; }
+} // namespace detail
+
+constexpr size_t payloadSizeOf(RenderCmd c)
+{
+    switch (c)
+    {
+#define RIVE_RENDER_CMD_SIZE_CASE(cmd, POD)                                    \
+    case RenderCmd::cmd:                                                       \
+        return detail::payloadSizeOfPOD<POD>();
+        RIVE_RENDER_CMD_TABLE(RIVE_RENDER_CMD_SIZE_CASE)
+#undef RIVE_RENDER_CMD_SIZE_CASE
+    }
+    return 0;
+}
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/render_handle.hpp b/renderer/include/rive/renderer/cmd/render_handle.hpp
new file mode 100644
index 0000000..05d1fc7
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/render_handle.hpp
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/handle_flags.hpp"
+#include <cstdint>
+
+// Dense resource id for the deferred 2D command stream.
+namespace rive::cmd
+{
+using RenderHandle = uint32_t;
+constexpr RenderHandle kInvalidRenderHandle = ~0u;
+
+// A canvas drawImage carries this flag; the low bits index the canvas table.
+// Test after kInvalidRenderHandle, which also has the high bit set.
+constexpr RenderHandle kCanvasHandleFlag = kHandleForeignFlag;
+constexpr RenderHandle kCanvasHandleMask = kHandleForeignMask;
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/render_replay.hpp b/renderer/include/rive/renderer/cmd/render_replay.hpp
new file mode 100644
index 0000000..08fa7d5
--- /dev/null
+++ b/renderer/include/rive/renderer/cmd/render_replay.hpp
@@ -0,0 +1,296 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/factory.hpp"
+#include <cstdio>
+#include "rive/renderer.hpp"
+#include "rive/shapes/paint/image_sampler.hpp"
+#include "rive/math/raw_path.hpp"
+#include "rive/math/mat2d.hpp"
+#include "rive/renderer/cmd/render_command_buffer.hpp"
+#include "rive/renderer/cmd/render_commands.hpp"
+#include <cassert>
+#include <cstring>
+#include <functional>
+#include <unordered_map>
+#include <vector>
+
+// Replays a recorded 2D command stream against a real Factory and Renderer.
+// Creation commands rebuild real resources into dense per type vectors,
+// mutations replay in order, and draws drive the real renderer.
+namespace rive::cmd
+{
+
+// Bulk copy is safe since the bytes came from a valid RawPath at record time.
+inline RawPath rebuildRawPath(Span<const uint8_t> verbBytes,
+                              Span<const uint8_t> pointBytes)
+{
+    return RawPath(
+        Span<const PathVerb>(
+            reinterpret_cast<const PathVerb*>(verbBytes.data()),
+            verbBytes.size() / sizeof(PathVerb)),
+        Span<const Vec2D>(reinterpret_cast<const Vec2D*>(pointBytes.data()),
+                          pointBytes.size() / sizeof(Vec2D)));
+}
+
+// Resolves a flagged canvas id to the real canvas's RenderImage at replay.
+// Null when the stream draws no canvases.
+using CanvasImageResolver = std::function<RenderImage*(RenderHandle canvasId)>;
+
+// Draw drop accounting for verification.
+struct ReplayStats
+{
+    uint32_t droppedDraws = 0;
+};
+
+namespace replay_detail
+{
+inline void logDroppedDraw(uint8_t type, uint32_t h0, uint32_t h1)
+{
+    RIVE_WARN_THROTTLED("rive replay: dropped draw opcode=%u handles=%u,%u "
+                        "(unresolved at replay)\n",
+                        type,
+                        h0,
+                        h1);
+}
+} // namespace replay_detail
+
+// Which command classes a walk executes. The filter only gates side effects;
+// reads always consume payloads so the walk stays in sync.
+enum class ReplayFilter : uint8_t
+{
+    all,
+    resources, // creates, mutations, version bumps, in record order
+    draws,     // draws, brackets, markers, in scheduled segment order
+    destroys,  // destroyResource
+};
+
+// Render thread hooks for a deferred runtime frame. A bare replay leaves them
+// null and everything draws into renderer.
+struct ReplayHooks
+{
+    ReplayFilter filter = ReplayFilter::all;
+    CanvasImageResolver canvasImage = nullptr;
+    // Open the real canvas's frame and return the renderer that draws into
+    // it; a null return safely drops that canvas's content.
+    std::function<Renderer*(RenderHandle canvasId, uint32_t clearColor)>
+        beginCanvasContent = nullptr;
+    // Nonzero dropped draws mean mixed factory recording or a replay bug.
+    ReplayStats* stats = nullptr;
+};
+
+// One resident resource type: a dense vector indexed by id, each slot carrying
+// the real object and its creation generation. Never compacts.
+template <typename T> struct Resident
+{
+    std::vector<rcp<T>> objects;
+    std::vector<uint32_t> generations;
+    // Draws pin the exact version they recorded against; older versions of a
+    // slot live in per frame aliases keyed (id << 32) | version.
+    std::vector<uint32_t> versions;
+    std::unordered_map<uint64_t, rcp<T>> versionAliases;
+
+    void set(RenderHandle id, rcp<T> obj, uint32_t generation)
+    {
+        if (id > objects.size())
+        {
+            // The producer mints ids sequentially, so a fresh id may only
+            // append; anything further ahead is a corrupt stream.
+            assert(false);
+            return;
+        }
+        if (id == objects.size())
+        {
+            objects.push_back(std::move(obj));
+            generations.push_back(generation);
+            versions.push_back(0);
+            return;
+        }
+        // A retaken id must not resolve to the old take's versions.
+        dropVersionAliases(id);
+        objects[id] = std::move(obj);
+        generations[id] = generation;
+        versions[id] = 0;
+    }
+    void dropVersionAliases(RenderHandle id)
+    {
+        if (versionAliases.empty())
+        {
+            return;
+        }
+        for (auto it = versionAliases.begin(); it != versionAliases.end();)
+        {
+            if (static_cast<RenderHandle>(it->first >> 32) == id)
+            {
+                it = versionAliases.erase(it);
+            }
+            else
+            {
+                ++it;
+            }
+        }
+    }
+    // Move the live object under its old version and make obj the live one.
+    void newVersion(RenderHandle id, uint32_t version, rcp<T> obj)
+    {
+        if (id >= objects.size())
+        {
+            return;
+        }
+        versionAliases[(uint64_t(id) << 32) | versions[id]] =
+            std::move(objects[id]);
+        objects[id] = std::move(obj);
+        versions[id] = version;
+    }
+    void destroy(RenderHandle id, uint32_t generation)
+    {
+        if (id < objects.size() && generations[id] == generation)
+        {
+            objects[id] = nullptr;
+            dropVersionAliases(id);
+        }
+    }
+    T* get(RenderHandle id) const
+    {
+        return id < objects.size() ? objects[id].get() : nullptr;
+    }
+    T* get(RenderHandle id, uint32_t version) const
+    {
+        if (id >= objects.size())
+        {
+            return nullptr;
+        }
+        if (version == versions[id])
+        {
+            return objects[id].get();
+        }
+        auto it = versionAliases.find((uint64_t(id) << 32) | version);
+        return it == versionAliases.end() ? nullptr : it->second.get();
+    }
+    // Owning rcp for APIs that take one.
+    rcp<T> shared(RenderHandle id) const
+    {
+        return id < objects.size() ? objects[id] : nullptr;
+    }
+    rcp<T> shared(RenderHandle id, uint32_t version) const
+    {
+        if (id >= objects.size())
+        {
+            return nullptr;
+        }
+        if (version == versions[id])
+        {
+            return objects[id];
+        }
+        auto it = versionAliases.find((uint64_t(id) << 32) | version);
+        return it == versionAliases.end() ? nullptr : it->second;
+    }
+};
+
+// The materialized 2D resources, persisted across frames so resources stay
+// resident on the render thread.
+// CPU shadows of mutable state, so a version bump can materialize a fresh
+// object carrying the outgoing state without cloning backend objects.
+struct PaintShadow
+{
+    // Must be the backend's defaults: a property the producer never sends is
+    // one it knows the fresh object already carries.
+    uint8_t style = 1; // fill; a fresh paint is unstroked until told
+    ColorInt color = 0xFF000000;
+    float thickness = 1;
+    uint8_t join = 0;
+    uint8_t cap = 0;
+    float feather = 0;
+    uint8_t blendMode = 3; // srcOver
+    RenderHandle shader = kInvalidRenderHandle;
+};
+struct BufferShadow
+{
+    uint8_t type = 0;
+    uint16_t flags = 0;
+    uint32_t size = 0;
+};
+
+struct ResourceTable
+{
+    Resident<RenderPath> paths;
+    Resident<RenderPaint> paints;
+    Resident<RenderShader> shaders;
+    Resident<RenderImage> images;
+    Resident<RenderBuffer> buffers;
+    std::vector<PaintShadow> paintShadows;
+    std::vector<uint8_t> pathFillRules;
+    std::vector<BufferShadow> bufferShadows;
+
+    // Generation checked so a stale destroy after slot reuse is a no-op.
+    void destroy(ResourceKind kind, RenderHandle id, uint32_t generation)
+    {
+        switch (kind)
+        {
+            case ResourceKind::path:
+                paths.destroy(id, generation);
+                break;
+            case ResourceKind::paint:
+                paints.destroy(id, generation);
+                break;
+            case ResourceKind::shader:
+                shaders.destroy(id, generation);
+                break;
+            case ResourceKind::image:
+                images.destroy(id, generation);
+                break;
+            case ResourceKind::buffer:
+                buffers.destroy(id, generation);
+                break;
+        }
+    }
+
+    // Old versions only live within one frame's replay.
+    void clearVersionAliases()
+    {
+        paths.versionAliases.clear();
+        paints.versionAliases.clear();
+        shaders.versionAliases.clear();
+        images.versionAliases.clear();
+        buffers.versionAliases.clear();
+    }
+};
+
+// Span form: replays raw stream bytes so a snapshot copy can replay without a
+// RenderCommandBuffer. Defined in src/deferred_cmd.cpp.
+void replayRenderCommands(Factory* factory,
+                          Renderer* renderer,
+                          Span<const uint8_t> commands,
+                          Span<const uint8_t> blobs,
+                          ResourceTable& table,
+                          const ReplayHooks& hooks = {});
+
+// Buffer form.
+inline void replayRenderCommands(Factory* factory,
+                                 Renderer* renderer,
+                                 const RenderCommandBuffer& cmd,
+                                 ResourceTable& table,
+                                 const ReplayHooks& hooks = {})
+{
+    replayRenderCommands(factory,
+                         renderer,
+                         cmd.commandBytes(),
+                         cmd.blobBytes(),
+                         table,
+                         hooks);
+}
+
+// Convenience form with a throwaway table.
+inline void replayRenderCommands(Factory* factory,
+                                 Renderer* renderer,
+                                 const RenderCommandBuffer& cmd,
+                                 const ReplayHooks& hooks = {})
+{
+    ResourceTable table;
+    replayRenderCommands(factory, renderer, cmd, table, hooks);
+}
+
+} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/gl/gl_utils.hpp b/renderer/include/rive/renderer/gl/gl_utils.hpp
index 1590f8a..5165ec5 100644
--- a/renderer/include/rive/renderer/gl/gl_utils.hpp
+++ b/renderer/include/rive/renderer/gl/gl_utils.hpp
@@ -73,11 +73,52 @@
 void LinkProgram(GLuint program,
                  DebugPrintErrorAndAbort = DebugPrintErrorAndAbort::yes);
 
+// Threaded wasm reaches one heap from several contexts, and a GL name means
+// nothing outside the context that made it: every context numbers from 1.
+#ifdef __EMSCRIPTEN_PTHREADS__
+#define RIVE_GL_NAMES_ARE_PER_CONTEXT
+#endif
+
+enum class GLObjectType
+{
+    buffer,
+    texture,
+    framebuffer,
+    renderbuffer,
+    vertexArray,
+    shader,
+    program,
+};
+
+using GLContextID = int;
+
+#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT
+GLContextID CurrentContextID();
+
+// Deletes the names other threads left behind for whichever context is current.
+void ReclaimAbandonedNames();
+
+// Process wide, for tests: names left to their owning context, and names that
+// owner has since deleted.
+uint32_t AbandonedNameCount();
+uint32_t ReclaimedNameCount();
+#else
+// A process with a single context can always delete what it created.
+constexpr GLContextID CurrentContextID() { return 0; }
+inline void ReclaimAbandonedNames() {}
+#endif
+
 class GLObject
 {
 public:
     GLObject() = default;
-    GLObject(GLObject&& rhs) : m_id(std::exchange(rhs.m_id, 0)) {}
+    GLObject(GLObject&& rhs) :
+        m_id(std::exchange(rhs.m_id, 0))
+#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT
+        ,
+        m_context(rhs.m_context)
+#endif
+    {}
 
     GLObject(const GLObject&) = delete;
     GLObject& operator=(const GLObject&) = delete;
@@ -87,14 +128,24 @@
 protected:
     explicit GLObject(GLuint adoptedID) : m_id(adoptedID) {}
 
+    // Deletes m_id, on the context that created it.
+    void destroy(GLObjectType);
+    // Deletes m_id and takes over rhs's name and the context it belongs to.
+    void adopt(GLObjectType, GLObject&& rhs);
+    // Deletes m_id and takes over a name generated on the current context.
+    void adoptName(GLObjectType, GLuint adoptedID);
+
     GLuint m_id = 0;
+#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT
+    GLContextID m_context = CurrentContextID();
+#endif
 };
 
 class Buffer : public GLObject
 {
 public:
     Buffer() { glGenBuffers(1, &m_id); }
-    ~Buffer() { glDeleteBuffers(1, &m_id); }
+    ~Buffer() { destroy(GLObjectType::buffer); }
 };
 
 class Texture : public GLObject
@@ -104,25 +155,16 @@
     Texture(Texture&& rhs) : GLObject(std::move(rhs)) {}
     Texture& operator=(Texture&& rhs)
     {
-        reset(std::exchange(rhs.m_id, 0));
+        adopt(GLObjectType::texture, std::move(rhs));
         return *this;
     }
-    ~Texture() { reset(0); }
+    ~Texture() { destroy(GLObjectType::texture); }
 
     static Texture Zero() { return Texture(0); }
     static Texture Adopt(GLuint id) { return Texture(id); }
 
 private:
     explicit Texture(GLuint adoptedID) : GLObject(adoptedID) {}
-
-    void reset(GLuint adoptedID)
-    {
-        if (m_id != 0)
-        {
-            glDeleteTextures(1, &m_id);
-        }
-        m_id = adoptedID;
-    }
 };
 
 class Framebuffer : public GLObject
@@ -132,24 +174,15 @@
     Framebuffer(Framebuffer&& rhs) : GLObject(std::move(rhs)) {}
     Framebuffer& operator=(Framebuffer&& rhs)
     {
-        reset(std::exchange(rhs.m_id, 0));
+        adopt(GLObjectType::framebuffer, std::move(rhs));
         return *this;
     }
-    ~Framebuffer() { reset(0); }
+    ~Framebuffer() { destroy(GLObjectType::framebuffer); }
 
     static Framebuffer Zero() { return Framebuffer(0); }
 
 private:
     explicit Framebuffer(GLuint adoptedID) : GLObject(adoptedID) {}
-
-    void reset(GLuint adoptedID)
-    {
-        if (m_id != 0)
-        {
-            glDeleteFramebuffers(1, &m_id);
-        }
-        m_id = adoptedID;
-    }
 };
 
 class Renderbuffer : public GLObject
@@ -159,31 +192,22 @@
     Renderbuffer(Renderbuffer&& rhs) : GLObject(std::move(rhs)) {}
     Renderbuffer& operator=(Renderbuffer&& rhs)
     {
-        reset(std::exchange(rhs.m_id, 0));
+        adopt(GLObjectType::renderbuffer, std::move(rhs));
         return *this;
     }
-    ~Renderbuffer() { reset(0); }
+    ~Renderbuffer() { destroy(GLObjectType::renderbuffer); }
 
     static Renderbuffer Zero() { return Renderbuffer(0); }
 
 private:
     explicit Renderbuffer(GLuint adoptedID) : GLObject(adoptedID) {}
-
-    void reset(GLuint adoptedID)
-    {
-        if (m_id != 0)
-        {
-            glDeleteRenderbuffers(1, &m_id);
-        }
-        m_id = adoptedID;
-    }
 };
 
 class VAO : public GLObject
 {
 public:
     VAO() { glGenVertexArrays(1, &m_id); }
-    ~VAO() { glDeleteVertexArrays(1, &m_id); }
+    ~VAO() { destroy(GLObjectType::vertexArray); }
 };
 
 class Shader : public GLObject
@@ -193,10 +217,10 @@
     Shader(Shader&& rhs) : GLObject(std::move(rhs)) {}
     Shader& operator=(Shader&& rhs)
     {
-        reset(std::exchange(rhs.m_id, 0));
+        adopt(GLObjectType::shader, std::move(rhs));
         return *this;
     }
-    ~Shader() { reset(0); }
+    ~Shader() { destroy(GLObjectType::shader); }
 
     void compile(GLenum type,
                  const char* source,
@@ -213,11 +237,7 @@
 
     void reset(GLuint adoptedID = 0)
     {
-        if (m_id != 0)
-        {
-            glDeleteShader(m_id);
-        }
-        m_id = adoptedID;
+        adoptName(GLObjectType::shader, adoptedID);
     }
 };
 
@@ -227,12 +247,12 @@
     Program() : GLObject(glCreateProgram()) {}
     Program& operator=(Program&& rhs)
     {
-        reset(std::exchange(rhs.m_id, 0));
+        adopt(GLObjectType::program, std::move(rhs));
         m_vertexShader = std::move(rhs.m_vertexShader);
         m_fragmentShader = std::move(rhs.m_fragmentShader);
         return *this;
     }
-    ~Program() { reset(0); }
+    ~Program() { destroy(GLObjectType::program); }
 
     void compileAndAttachShader(GLenum type,
                                 const char* source,
@@ -254,8 +274,6 @@
 private:
     explicit Program(GLuint adoptedID) : GLObject(adoptedID) {}
 
-    void reset(GLuint adoptedProgramID);
-
     glutils::Shader m_vertexShader;
     glutils::Shader m_fragmentShader;
 };
diff --git a/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp b/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp
index b89d89e..6342292 100644
--- a/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp
+++ b/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp
@@ -9,7 +9,10 @@
 #include "rive/renderer/gl/gl_utils.hpp"
 #include "rive/renderer/render_context_helper_impl.hpp"
 
+#include <atomic>
+#include <mutex>
 #include <unordered_map>
+#include <vector>
 
 namespace rive
 {
@@ -69,6 +72,15 @@
     rcp<RenderCanvas> makeRenderCanvas(uint32_t width,
                                        uint32_t height) override;
 
+    // Creates a shell canvas with no texture; the deferred replay worker
+    // backs it on its own context via ensureDeferredCanvasBacking.
+    rcp<RenderCanvas> makeDeferredRenderCanvas(uint32_t width,
+                                               uint32_t height) override;
+
+    // Back a deferred canvas with a texture on this context so it reads
+    // coherently here. No-op for an already backed canvas.
+    void ensureDeferredCanvasBacking(gpu::RenderCanvas* canvas);
+
     std::unique_ptr<rive::ore::Context> makeOreContext() override;
 
     // GL-only: returns a Y-flipped companion of a Rive 2D RenderCanvas
@@ -105,6 +117,10 @@
     void registerCanvasTarget(GLuint sourceTex);
     void unregisterCanvasTarget(GLuint sourceTex);
 
+    // A deferred canvas is dropped by the thread that recorded it, so its entry
+    // and the FBOs in it come down on this context's own thread instead.
+    void releaseCanvasTarget(GLuint sourceTex);
+
     // Looks up an existing mirror for `sourceTex` and allocates one if
     // none exists yet. Returns nullptr if `sourceTex` was never registered
     // (i.e. is not a canvas target — caller should fall through to a
@@ -186,6 +202,13 @@
 #endif
 
 private:
+#ifdef RIVE_CANVAS
+    // Shared canvas wiring; `tex` of 0 makes an unbacked shell canvas.
+    rcp<RenderCanvas> wrapCanvasBacking(uint32_t width,
+                                        uint32_t height,
+                                        GLuint tex);
+#endif
+
     class DrawProgram;
 
     // Manages how we implement pixel local storage in shaders.
@@ -575,6 +598,14 @@
     };
     std::unordered_map<GLuint, CanvasMirrorEntry> m_canvasMirrors;
     friend class CanvasMirrorTextureGLImpl;
+
+    // Canvas targets released off this context's thread, drained by flush.
+    std::mutex m_releasedCanvasTargetMutex;
+    std::vector<GLuint> m_releasedCanvasTargets;
+    std::atomic<bool> m_hasReleasedCanvasTargets{false};
+    const glutils::GLContextID m_glContext = glutils::CurrentContextID();
+
+    void drainReleasedCanvasTargets();
 #endif
 };
 } // namespace rive::gpu
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_command_buffer.hpp b/renderer/include/rive/renderer/ore/cmd/ore_command_buffer.hpp
new file mode 100644
index 0000000..6857596
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_command_buffer.hpp
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/command_stream.hpp"
+#include "rive/renderer/cmd/recording_thread.hpp"
+#include "rive/renderer/ore/cmd/ore_commands.hpp"
+#include "rive/renderer/gpu_resource.hpp"
+#include "rive/refcnt.hpp"
+#include "rive/span.hpp"
+#include <cassert>
+#include <cstdint>
+#include <cstring>
+#include <functional>
+#include <mutex>
+#include <unordered_map>
+#include "rive/renderer/cmd/id_allocator.hpp"
+#include <vector>
+
+// Records the ore RenderPass call stream into a flat byte stream (see
+// ore_commands.hpp). Upload payloads live in a companion blob arena. Both
+// vectors are reused across frames via reset, keeping capacity.
+namespace rive::ore::cmd
+{
+
+class OreCommandBuffer : public rive::cmd::CommandByteStream
+{
+public:
+    // Claim this stream for the calling thread; every append after it must
+    // come from the same one. Deferred producers call it, an inline or
+    // backend owned buffer leaves it unbound. Debug only, see RecordingThread.
+    void bindRecordingThread() { m_recordingThread.bind(); }
+
+    // When set, capture returns the provider's stable handle instead of a
+    // buffer local keep alive index and does not retain the resource here.
+    // Only reached for a resource that is not one of the recorder's own
+    // deferred objects; those report their handle themselves.
+    std::function<ResourceHandle(rive::gpu::GPUResource*)> realHandleProvider;
+
+    // Dedups by pointer so repeated binds cost one ref. Callers must have
+    // ruled out a deferred object first: this retains res and hands back an
+    // index into the keep alive table, which replay reads as a real resource.
+    ResourceHandle capture(rive::gpu::GPUResource* res)
+    {
+        if (res == nullptr)
+        {
+            return kInvalidHandle;
+        }
+        m_recordingThread.check();
+        if (realHandleProvider)
+        {
+            return realHandleProvider(res);
+        }
+        auto it = m_resourceIds.find(res);
+        if (it != m_resourceIds.end())
+        {
+            return it->second;
+        }
+        ResourceHandle h = static_cast<ResourceHandle>(m_keepAlive.size());
+        m_keepAlive.push_back(rive::ref_rcp(res));
+        m_resourceIds.emplace(res, h);
+        return h;
+    }
+
+    template <typename POD> void append(CommandType type, const POD& pod)
+    {
+        m_recordingThread.check();
+        appendUnchecked(type, pod);
+    }
+
+    void appendOpcode(CommandType type)
+    {
+        m_recordingThread.check();
+        writeRaw(&type, sizeof(type));
+    }
+
+    // Payload with no opcode, e.g. a make descriptor after its header.
+    template <typename POD> void appendPayload(const POD& pod)
+    {
+        static_assert(std::is_trivially_copyable<POD>::value);
+        m_recordingThread.check();
+        writeRaw(&pod, sizeof(pod));
+    }
+
+    // absent records a null source as distinct from an empty payload.
+    BlobRef appendBlobRef(const void* data, uint32_t size, bool absent)
+    {
+        if (absent)
+        {
+            return kNoBlob;
+        }
+        return {appendBlob(data, size), size, 0};
+    }
+    // A null pointer maps to absent.
+    BlobRef appendStringRef(const char* s)
+    {
+        if (s == nullptr)
+        {
+            return kNoBlob;
+        }
+        uint32_t len = static_cast<uint32_t>(std::strlen(s)) + 1; // include NUL
+        return appendBlobRef(s, len, false);
+    }
+
+    // Dart finalizers destroy on GC threads, so destroys queue and drain on
+    // the recording thread. The erase is generation checked.
+    struct PendingDestroy
+    {
+        ResourceHandle handle;
+        uint32_t generation;
+        rive::IdAllocator<ResourceHandle>* allocator;
+    };
+
+private:
+    template <typename POD> void appendUnchecked(CommandType type, const POD& p)
+    {
+        static_assert(std::is_trivially_copyable<POD>::value);
+        writeRaw(&type, sizeof(type));
+        writeRaw(&p, sizeof(p));
+    }
+
+    void applyDestroy(const PendingDestroy& p)
+    {
+        // Unchecked: the last drain of a session's life runs from wherever
+        // the host posted its teardown, which on threaded wasm is the replay
+        // worker rather than the recording thread.
+        appendUnchecked(CommandType::destroyResource,
+                        DestroyResourcePOD{p.handle, p.generation});
+        if (p.allocator != nullptr)
+        {
+            p.allocator->release(p.handle, p.generation);
+        }
+    }
+
+public:
+    void queueDestroy(const PendingDestroy& pending)
+    {
+        std::lock_guard<std::mutex> lock(m_destroyMutex);
+        m_pendingDestroys.push_back(pending);
+    }
+
+    // The id goes straight back, so the next create in this same stream may
+    // retake it. Safe because a consumer replays whole frames in stream order
+    // and every resident slot is generation checked: the retake's create
+    // stamps a new generation, so this destroy no-ops when it replays and a
+    // snapshot recorded before it still resolves its own generation out of its
+    // own byte copy.
+    void drainDestroys()
+    {
+        std::vector<PendingDestroy> pending;
+        {
+            std::lock_guard<std::mutex> lock(m_destroyMutex);
+            pending.swap(m_pendingDestroys);
+        }
+        for (const auto& p : pending)
+        {
+            applyDestroy(p);
+        }
+    }
+
+    // Keeps capacity for reuse across frames.
+    void reset()
+    {
+        m_recordingThread.check();
+        clearBytes();
+        m_keepAlive.clear();
+        m_resourceIds.clear();
+    }
+
+    const std::vector<rcp<rive::gpu::GPUResource>>& keepAlive() const
+    {
+        return m_keepAlive;
+    }
+
+private:
+    rive::cmd::RecordingThread m_recordingThread;
+    std::mutex m_destroyMutex;
+    std::vector<PendingDestroy> m_pendingDestroys;
+    std::vector<rcp<rive::gpu::GPUResource>> m_keepAlive;
+    std::unordered_map<rive::gpu::GPUResource*, ResourceHandle> m_resourceIds;
+};
+
+// Sequential reader shared by backend replay, the silver comparator, and a
+// viewer.
+using OreCommandReader = rive::cmd::CommandReader<CommandType>;
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_command_silver.hpp b/renderer/include/rive/renderer/ore/cmd/ore_command_silver.hpp
new file mode 100644
index 0000000..f67e4b9
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_command_silver.hpp
@@ -0,0 +1,431 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/core/binary_reader.hpp"
+#include "rive/core/vector_binary_writer.hpp"
+#include <cmath>
+#include <cstdio>
+#include <string>
+#include <vector>
+
+// Portable field wise silver form of a recorded ore command stream.
+// serializeSilver emits ints as varuints and floats via writeFloat, so the
+// layout is independent of POD padding and arch. silverMatch compares two
+// streams, the GPU free regression guard for recording fidelity.
+namespace rive::ore::cmd
+{
+
+// "ORES" + version. Distinct from the in-memory "ORECMD" stream.
+constexpr uint8_t kSilverMagic[4] = {'O', 'R', 'E', 'S'};
+constexpr uint64_t kSilverVersion = 1;
+constexpr float kSilverEpsilon = 0.0001f;
+
+inline void serializeSilver(const OreCommandBuffer& buffer,
+                            std::vector<uint8_t>& out)
+{
+    VectorBinaryWriter writer(&out);
+    writer.write(kSilverMagic, sizeof(kSilverMagic));
+    writer.writeVarUint(kSilverVersion);
+
+    OreCommandReader reader(buffer.commandBytes(), buffer.blobBytes());
+    CommandType type;
+    while (reader.next(type))
+    {
+        writer.writeVarUint(static_cast<uint32_t>(type));
+        switch (type)
+        {
+            case CommandType::beginRenderPass:
+            {
+                auto cmd = reader.read<BeginRenderPassCmd>();
+                writer.writeVarUint(cmd.colorCount);
+                for (uint32_t i = 0; i < cmd.colorCount; ++i)
+                {
+                    const ColorAttachmentPOD& c = cmd.colors[i];
+                    writer.writeVarUint(c.view);
+                    writer.writeVarUint(c.resolveTarget);
+                    writer.writeVarUint(static_cast<uint32_t>(c.loadOp));
+                    writer.writeVarUint(static_cast<uint32_t>(c.storeOp));
+                    writer.writeFloat(c.clearR);
+                    writer.writeFloat(c.clearG);
+                    writer.writeFloat(c.clearB);
+                    writer.writeFloat(c.clearA);
+                }
+                const DepthStencilAttachmentPOD& d = cmd.depthStencil;
+                writer.writeVarUint(d.view);
+                writer.writeVarUint(static_cast<uint32_t>(d.depthLoadOp));
+                writer.writeVarUint(static_cast<uint32_t>(d.depthStoreOp));
+                writer.writeFloat(d.depthClearValue);
+                writer.writeVarUint(static_cast<uint32_t>(d.stencilLoadOp));
+                writer.writeVarUint(static_cast<uint32_t>(d.stencilStoreOp));
+                writer.writeVarUint(d.stencilClearValue);
+                break;
+            }
+            case CommandType::setPipeline:
+            {
+                auto cmd = reader.read<SetPipelineCmd>();
+                writer.writeVarUint(cmd.pipeline);
+                break;
+            }
+            case CommandType::setVertexBuffer:
+            {
+                auto cmd = reader.read<SetVertexBufferCmd>();
+                writer.writeVarUint(cmd.slot);
+                writer.writeVarUint(cmd.buffer);
+                writer.writeVarUint(cmd.offset);
+                break;
+            }
+            case CommandType::setIndexBuffer:
+            {
+                auto cmd = reader.read<SetIndexBufferCmd>();
+                writer.writeVarUint(cmd.buffer);
+                writer.writeVarUint(static_cast<uint32_t>(cmd.format));
+                writer.writeVarUint(cmd.offset);
+                break;
+            }
+            case CommandType::setBindGroup:
+            {
+                auto cmd = reader.read<SetBindGroupCmd>();
+                writer.writeVarUint(cmd.groupIndex);
+                writer.writeVarUint(cmd.bindGroup);
+                writer.writeVarUint(cmd.dynamicOffsetCount);
+                Span<const uint8_t> blob =
+                    reader.blobAt(cmd.dynamicOffsetStart,
+                                  cmd.dynamicOffsetCount * sizeof(uint32_t));
+                for (uint32_t i = 0; i < cmd.dynamicOffsetCount; ++i)
+                {
+                    uint32_t off;
+                    std::memcpy(&off,
+                                blob.data() + i * sizeof(uint32_t),
+                                sizeof(uint32_t));
+                    writer.writeVarUint(off);
+                }
+                break;
+            }
+            case CommandType::setViewport:
+            {
+                auto cmd = reader.read<SetViewportCmd>();
+                writer.writeFloat(cmd.x);
+                writer.writeFloat(cmd.y);
+                writer.writeFloat(cmd.width);
+                writer.writeFloat(cmd.height);
+                writer.writeFloat(cmd.minDepth);
+                writer.writeFloat(cmd.maxDepth);
+                break;
+            }
+            case CommandType::setScissorRect:
+            {
+                auto cmd = reader.read<SetScissorRectCmd>();
+                writer.writeVarUint(cmd.x);
+                writer.writeVarUint(cmd.y);
+                writer.writeVarUint(cmd.width);
+                writer.writeVarUint(cmd.height);
+                break;
+            }
+            case CommandType::setStencilReference:
+            {
+                auto cmd = reader.read<SetStencilReferenceCmd>();
+                writer.writeVarUint(cmd.ref);
+                break;
+            }
+            case CommandType::setBlendColor:
+            {
+                auto cmd = reader.read<SetBlendColorCmd>();
+                writer.writeFloat(cmd.r);
+                writer.writeFloat(cmd.g);
+                writer.writeFloat(cmd.b);
+                writer.writeFloat(cmd.a);
+                break;
+            }
+            case CommandType::draw:
+            {
+                auto cmd = reader.read<DrawCmd>();
+                writer.writeVarUint(cmd.vertexCount);
+                writer.writeVarUint(cmd.instanceCount);
+                writer.writeVarUint(cmd.firstVertex);
+                writer.writeVarUint(cmd.firstInstance);
+                break;
+            }
+            case CommandType::drawIndexed:
+            {
+                auto cmd = reader.read<DrawIndexedCmd>();
+                writer.writeVarUint(cmd.indexCount);
+                writer.writeVarUint(cmd.instanceCount);
+                writer.writeVarUint(cmd.firstIndex);
+                // baseVertex is signed, round trip its bit pattern.
+                writer.writeVarUint(static_cast<uint32_t>(cmd.baseVertex));
+                writer.writeVarUint(cmd.firstInstance);
+                break;
+            }
+            case CommandType::finish:
+                break;
+
+            // Lifecycle opcodes do not appear in the pass only streams compared
+            // today, but the reader must still advance; emit only the identity.
+            case CommandType::makeBuffer:
+            case CommandType::makeTexture:
+            case CommandType::makeSampler:
+            case CommandType::makeShaderModule:
+            case CommandType::makeBindGroupLayout:
+            case CommandType::makeTextureView:
+            case CommandType::makePipeline:
+            case CommandType::makeBindGroup:
+            {
+                auto m = reader.read<MakeResourcePOD>();
+                reader.skip(orePayloadSizeOf(type) - sizeof(MakeResourcePOD));
+                writer.writeVarUint(m.id);
+                writer.writeVarUint(m.generation);
+                break;
+            }
+            case CommandType::bufferUpdate:
+            {
+                auto cmd = reader.read<BufferUpdatePOD>();
+                writer.writeVarUint(cmd.handle);
+                writer.writeVarUint(cmd.offset);
+                writer.writeVarUint(cmd.bytes.size);
+                break;
+            }
+            case CommandType::textureUpload:
+            {
+                auto cmd = reader.read<TextureUploadPOD>();
+                writer.writeVarUint(cmd.handle);
+                writer.writeVarUint(cmd.bytes.size);
+                break;
+            }
+            case CommandType::destroyResource:
+            {
+                auto cmd = reader.read<DestroyResourcePOD>();
+                writer.writeVarUint(cmd.handle);
+                writer.writeVarUint(cmd.generation);
+                break;
+            }
+            case CommandType::wrapCanvasView:
+            {
+                auto cmd = reader.read<WrapCanvasViewPOD>();
+                writer.writeVarUint(cmd.id);
+                writer.writeVarUint(cmd.generation);
+                writer.writeVarUint(cmd.canvasId);
+                break;
+            }
+        }
+    }
+}
+
+namespace silver_detail
+{
+inline bool varMatch(const char* field,
+                     BinaryReader& a,
+                     BinaryReader& b,
+                     uint64_t* out = nullptr)
+{
+    uint64_t va = a.readVarUint64();
+    uint64_t vb = b.readVarUint64();
+    if (va != vb)
+    {
+        fprintf(stderr,
+                "ore silver: %s differs %llu != %llu\n",
+                field,
+                static_cast<unsigned long long>(va),
+                static_cast<unsigned long long>(vb));
+        return false;
+    }
+    if (out != nullptr)
+    {
+        *out = va;
+    }
+    return true;
+}
+
+inline bool floatMatch(const char* field, BinaryReader& a, BinaryReader& b)
+{
+    float va = a.readFloat32();
+    float vb = b.readFloat32();
+    if (std::fabs(va - vb) > kSilverEpsilon)
+    {
+        fprintf(stderr, "ore silver: %s differs %f != %f\n", field, va, vb);
+        return false;
+    }
+    return true;
+}
+} // namespace silver_detail
+
+// Compares within the float epsilon and reports the first divergence.
+inline bool silverMatch(const std::vector<uint8_t>& expected,
+                        const std::vector<uint8_t>& actual)
+{
+    using namespace silver_detail;
+    BinaryReader a(Span<const uint8_t>(expected.data(), expected.size()));
+    BinaryReader b(Span<const uint8_t>(actual.data(), actual.size()));
+
+    for (uint32_t i = 0; i < sizeof(kSilverMagic); ++i)
+    {
+        if (a.readByte() != kSilverMagic[i] || b.readByte() != kSilverMagic[i])
+        {
+            fprintf(stderr, "ore silver: bad magic\n");
+            return false;
+        }
+    }
+    if (!varMatch("version", a, b))
+    {
+        return false;
+    }
+
+    while (!a.reachedEnd())
+    {
+        if (b.reachedEnd())
+        {
+            fprintf(stderr, "ore silver: actual stream is shorter\n");
+            return false;
+        }
+        uint64_t op = 0;
+        if (!varMatch("opcode", a, b, &op))
+        {
+            return false;
+        }
+        switch (static_cast<CommandType>(op))
+        {
+            case CommandType::beginRenderPass:
+            {
+                uint64_t colorCount = 0;
+                if (!varMatch("colorCount", a, b, &colorCount))
+                {
+                    return false;
+                }
+                for (uint64_t i = 0; i < colorCount; ++i)
+                {
+                    if (!varMatch("color.view", a, b) ||
+                        !varMatch("color.resolveTarget", a, b) ||
+                        !varMatch("color.loadOp", a, b) ||
+                        !varMatch("color.storeOp", a, b) ||
+                        !floatMatch("color.clearR", a, b) ||
+                        !floatMatch("color.clearG", a, b) ||
+                        !floatMatch("color.clearB", a, b) ||
+                        !floatMatch("color.clearA", a, b))
+                    {
+                        return false;
+                    }
+                }
+                if (!varMatch("ds.view", a, b) ||
+                    !varMatch("ds.depthLoadOp", a, b) ||
+                    !varMatch("ds.depthStoreOp", a, b) ||
+                    !floatMatch("ds.depthClearValue", a, b) ||
+                    !varMatch("ds.stencilLoadOp", a, b) ||
+                    !varMatch("ds.stencilStoreOp", a, b) ||
+                    !varMatch("ds.stencilClearValue", a, b))
+                {
+                    return false;
+                }
+                break;
+            }
+            case CommandType::setPipeline:
+                if (!varMatch("pipeline", a, b))
+                {
+                    return false;
+                }
+                break;
+            case CommandType::setVertexBuffer:
+                if (!varMatch("vb.slot", a, b) ||
+                    !varMatch("vb.buffer", a, b) ||
+                    !varMatch("vb.offset", a, b))
+                {
+                    return false;
+                }
+                break;
+            case CommandType::setIndexBuffer:
+                if (!varMatch("ib.buffer", a, b) ||
+                    !varMatch("ib.format", a, b) ||
+                    !varMatch("ib.offset", a, b))
+                {
+                    return false;
+                }
+                break;
+            case CommandType::setBindGroup:
+            {
+                uint64_t count = 0;
+                if (!varMatch("bg.groupIndex", a, b) ||
+                    !varMatch("bg.bindGroup", a, b) ||
+                    !varMatch("bg.dynamicOffsetCount", a, b, &count))
+                {
+                    return false;
+                }
+                for (uint64_t i = 0; i < count; ++i)
+                {
+                    if (!varMatch("bg.dynamicOffset", a, b))
+                    {
+                        return false;
+                    }
+                }
+                break;
+            }
+            case CommandType::setViewport:
+                if (!floatMatch("vp.x", a, b) || !floatMatch("vp.y", a, b) ||
+                    !floatMatch("vp.width", a, b) ||
+                    !floatMatch("vp.height", a, b) ||
+                    !floatMatch("vp.minDepth", a, b) ||
+                    !floatMatch("vp.maxDepth", a, b))
+                {
+                    return false;
+                }
+                break;
+            case CommandType::setScissorRect:
+                if (!varMatch("sc.x", a, b) || !varMatch("sc.y", a, b) ||
+                    !varMatch("sc.width", a, b) || !varMatch("sc.height", a, b))
+                {
+                    return false;
+                }
+                break;
+            case CommandType::setStencilReference:
+                if (!varMatch("stencilRef", a, b))
+                {
+                    return false;
+                }
+                break;
+            case CommandType::setBlendColor:
+                if (!floatMatch("blend.r", a, b) ||
+                    !floatMatch("blend.g", a, b) ||
+                    !floatMatch("blend.b", a, b) ||
+                    !floatMatch("blend.a", a, b))
+                {
+                    return false;
+                }
+                break;
+            case CommandType::draw:
+                if (!varMatch("draw.vertexCount", a, b) ||
+                    !varMatch("draw.instanceCount", a, b) ||
+                    !varMatch("draw.firstVertex", a, b) ||
+                    !varMatch("draw.firstInstance", a, b))
+                {
+                    return false;
+                }
+                break;
+            case CommandType::drawIndexed:
+                if (!varMatch("drawIndexed.indexCount", a, b) ||
+                    !varMatch("drawIndexed.instanceCount", a, b) ||
+                    !varMatch("drawIndexed.firstIndex", a, b) ||
+                    !varMatch("drawIndexed.baseVertex", a, b) ||
+                    !varMatch("drawIndexed.firstInstance", a, b))
+                {
+                    return false;
+                }
+                break;
+            case CommandType::finish:
+                break;
+            default:
+                fprintf(stderr,
+                        "ore silver: unknown opcode %llu\n",
+                        static_cast<unsigned long long>(op));
+                return false;
+        }
+    }
+    if (!b.reachedEnd())
+    {
+        fprintf(stderr, "ore silver: actual stream is longer\n");
+        return false;
+    }
+    return true;
+}
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp b/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp
new file mode 100644
index 0000000..54b2536
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp
@@ -0,0 +1,277 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/ore_types.hpp"
+#include "rive/renderer/ore/cmd/ore_handle.hpp"
+#include "rive/renderer/ore/cmd/ore_resource_commands.hpp"
+#include <cstddef>
+#include <cstdint>
+
+// Recorded form of the ore RenderPass interface, written as [opcode][POD]
+// into a flat byte stream. Structs hold no pointers, so a recorded buffer is
+// movable across threads and doubles as the silver artifact. Resources are
+// referenced by ResourceHandle.
+namespace rive::ore::cmd
+{
+
+enum class CommandType : uint32_t
+{
+    beginRenderPass,
+    setPipeline,
+    setVertexBuffer,
+    setIndexBuffer,
+    setBindGroup,
+    setViewport,
+    setScissorRect,
+    setStencilReference,
+    setBlendColor,
+    draw,
+    drawIndexed,
+    finish,
+
+    // Resource lifecycle interleaved in stream order, so a create precedes
+    // every use and id reuse is safe on the consumer.
+    makeBuffer,
+    makeTexture,
+    makeSampler,
+    makeShaderModule,
+    makeBindGroupLayout,
+    makeTextureView,
+    makePipeline,
+    makeBindGroup,
+    bufferUpdate,
+    textureUpload,
+    destroyResource,
+    // Reserve a canvas view; the consumer wraps at replay. No device touch on
+    // record.
+    wrapCanvasView,
+};
+
+// Precedes each make descriptor; the consumer stores the real resource at
+// {id, generation}.
+struct MakeResourcePOD
+{
+    ResourceHandle id;
+    uint32_t generation;
+};
+
+struct BufferUpdatePOD
+{
+    ResourceHandle handle;
+    uint32_t offset;
+    BlobRef bytes;
+};
+
+// Fields are fixed width so the layout is identical on 32 bit wasm and 64 bit
+// native.
+struct TextureUploadPOD
+{
+    ResourceHandle handle;
+    uint32_t bytesPerRow;
+    uint32_t rowsPerImage;
+    uint32_t mipLevel;
+    uint32_t layer;
+    uint32_t x, y, z;
+    uint32_t width, height, depth;
+    uint32_t pad; // keeps the 8-aligned BlobRef free of implicit padding
+    BlobRef bytes;
+};
+static_assert(sizeof(TextureUploadPOD) == 16 * sizeof(uint32_t),
+              "wire POD must be pointer-free and padding-free");
+
+// Selects how the consumer wraps a reserved canvas view at replay.
+enum class WrapCanvasViewMode : uint32_t
+{
+    colorView = 0,  // the canvas's own render target view
+    sampleView = 1, // sampling wrap, on GL needs the top up mirror
+    imageView = 2,  // decoded image, canvasId carries the 2D image id
+};
+
+struct WrapCanvasViewPOD
+{
+    ResourceHandle id;
+    uint32_t generation;
+    uint32_t canvasId; // canvas id, or the 2D image id for imageView
+    uint32_t mode;     // WrapCanvasViewMode
+};
+
+// The consumer clears the slot only when the generation matches, so a stale
+// destroy for a recycled id is ignored.
+struct DestroyResourcePOD
+{
+    ResourceHandle handle;
+    uint32_t generation;
+};
+
+// resolveTarget == kInvalidHandle means none.
+struct ColorAttachmentPOD
+{
+    ResourceHandle view;
+    ResourceHandle resolveTarget;
+    LoadOp loadOp;
+    StoreOp storeOp;
+    float clearR;
+    float clearG;
+    float clearB;
+    float clearA;
+};
+
+// view == kInvalidHandle means no depth stencil attachment.
+struct DepthStencilAttachmentPOD
+{
+    ResourceHandle view;
+    LoadOp depthLoadOp;
+    StoreOp depthStoreOp;
+    float depthClearValue;
+    LoadOp stencilLoadOp;
+    StoreOp stencilStoreOp;
+    uint32_t stencilClearValue;
+};
+
+// Fixed 4 slot color array keeps the command a flat POD.
+struct BeginRenderPassCmd
+{
+    uint32_t colorCount;
+    ColorAttachmentPOD colors[4];
+    DepthStencilAttachmentPOD depthStencil;
+};
+
+struct SetPipelineCmd
+{
+    ResourceHandle pipeline;
+};
+
+struct SetVertexBufferCmd
+{
+    uint32_t slot;
+    ResourceHandle buffer;
+    uint32_t offset;
+};
+
+struct SetIndexBufferCmd
+{
+    ResourceHandle buffer;
+    IndexFormat format;
+    uint32_t offset;
+};
+
+// Dynamic offsets live in the blob arena at dynamicOffsetStart.
+struct SetBindGroupCmd
+{
+    uint32_t groupIndex;
+    ResourceHandle bindGroup;
+    uint64_t dynamicOffsetStart;
+    uint32_t dynamicOffsetCount;
+    uint32_t pad;
+};
+static_assert(sizeof(SetBindGroupCmd) == 6 * sizeof(uint32_t),
+              "wire POD must be pointer-free and padding-free");
+
+struct SetViewportCmd
+{
+    float x;
+    float y;
+    float width;
+    float height;
+    float minDepth;
+    float maxDepth;
+};
+
+struct SetScissorRectCmd
+{
+    uint32_t x;
+    uint32_t y;
+    uint32_t width;
+    uint32_t height;
+};
+
+struct SetStencilReferenceCmd
+{
+    uint32_t ref;
+};
+
+struct SetBlendColorCmd
+{
+    float r;
+    float g;
+    float b;
+    float a;
+};
+
+struct DrawCmd
+{
+    uint32_t vertexCount;
+    uint32_t instanceCount;
+    uint32_t firstVertex;
+    uint32_t firstInstance;
+};
+
+struct DrawIndexedCmd
+{
+    uint32_t indexCount;
+    uint32_t instanceCount;
+    uint32_t firstIndex;
+    int32_t baseVertex;
+    uint32_t firstInstance;
+};
+
+// Intentionally no union node type; readers switch on CommandType and memcpy
+// the POD, avoiding the union's worst case padding.
+
+// Opcode to payload table, one X(opcode, POD, DescPOD) per command; void
+// means no payload in that slot. make* carries MakeResourcePOD then its
+// descriptor. Every size a skip walk uses derives from here, so a new command
+// cannot desync it. Blobs ride separately and never affect sizes.
+#define RIVE_ORE_CMD_TABLE(X)                                                  \
+    X(beginRenderPass, BeginRenderPassCmd, void)                               \
+    X(setPipeline, SetPipelineCmd, void)                                       \
+    X(setVertexBuffer, SetVertexBufferCmd, void)                               \
+    X(setIndexBuffer, SetIndexBufferCmd, void)                                 \
+    X(setBindGroup, SetBindGroupCmd, void)                                     \
+    X(setViewport, SetViewportCmd, void)                                       \
+    X(setScissorRect, SetScissorRectCmd, void)                                 \
+    X(setStencilReference, SetStencilReferenceCmd, void)                       \
+    X(setBlendColor, SetBlendColorCmd, void)                                   \
+    X(draw, DrawCmd, void)                                                     \
+    X(drawIndexed, DrawIndexedCmd, void)                                       \
+    X(finish, void, void)                                                      \
+    X(makeBuffer, MakeResourcePOD, BufferDescPOD)                              \
+    X(makeTexture, MakeResourcePOD, TextureDescPOD)                            \
+    X(makeSampler, MakeResourcePOD, SamplerDescPOD)                            \
+    X(makeShaderModule, MakeResourcePOD, ShaderModuleDescPOD)                  \
+    X(makeBindGroupLayout, MakeResourcePOD, BindGroupLayoutDescPOD)            \
+    X(makeTextureView, MakeResourcePOD, TextureViewDescPOD)                    \
+    X(makePipeline, MakeResourcePOD, PipelineDescPOD)                          \
+    X(makeBindGroup, MakeResourcePOD, BindGroupDescPOD)                        \
+    X(bufferUpdate, BufferUpdatePOD, void)                                     \
+    X(textureUpload, TextureUploadPOD, void)                                   \
+    X(destroyResource, DestroyResourcePOD, void)                               \
+    X(wrapCanvasView, WrapCanvasViewPOD, void)
+
+namespace detail
+{
+template <typename POD> constexpr size_t orePayloadSizeOfPOD()
+{
+    return sizeof(POD);
+}
+template <> constexpr size_t orePayloadSizeOfPOD<void>() { return 0; }
+} // namespace detail
+
+constexpr size_t orePayloadSizeOf(CommandType c)
+{
+    switch (c)
+    {
+#define RIVE_ORE_CMD_SIZE_CASE(cmd, POD, DESC)                                 \
+    case CommandType::cmd:                                                     \
+        return detail::orePayloadSizeOfPOD<POD>() +                            \
+               detail::orePayloadSizeOfPOD<DESC>();
+        RIVE_ORE_CMD_TABLE(RIVE_ORE_CMD_SIZE_CASE)
+#undef RIVE_ORE_CMD_SIZE_CASE
+    }
+    return 0;
+}
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp b/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp
new file mode 100644
index 0000000..26e80b6
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp
@@ -0,0 +1,546 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_resource.hpp"
+#include "rive/renderer/ore/cmd/ore_make_recording.hpp"
+#include "rive/renderer/ore/cmd/ore_make_replay.hpp"
+#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp"
+#include "rive/renderer/ore/cmd/ore_replay.hpp"
+#include "rive/renderer/cmd/id_allocator.hpp"
+#include "rive/renderer/cmd/foreign_image_registry.hpp"
+#include "rive/renderer/cmd/live_recorder_registry.hpp"
+#include "rive/renderer/ore/ore_context.hpp"
+#include "utils/lite_rtti.hpp"
+#include <cassert>
+#include <unordered_map>
+#include <vector>
+
+// Ore Context used while recording in deferred mode. make* and beginRenderPass
+// record into one ordered stream with no GPU work; replayFrame materializes it
+// on a real context. Stream order makes id reuse safe, so the allocator can
+// recycle handles without a recycled id aliasing a live resource.
+namespace rive::ore::cmd
+{
+
+class DeferredOreContext : public Context
+{
+public:
+    // real may be null at construction and bound later via bindReal.
+    // Recording never touches it, but its capabilities are the ones a script
+    // must see, so they are copied in as soon as there is a device to copy.
+    explicit DeferredOreContext(Context* real) : Context(nullptr), m_real(real)
+    {
+        adoptRealFeatures();
+        m_render.realHandleProvider = [this](rive::gpu::GPUResource* r) {
+            return realHandleFor(r);
+        };
+        m_render.bindRecordingThread();
+        rive::cmd::registerRecorder(&m_render);
+        rive::cmd::registerRecorder(&m_ids);
+    }
+
+    ~DeferredOreContext() override
+    {
+        // Unregister first so a late finalizer release no-ops instead of
+        // writing into a dead recorder.
+        rive::cmd::unregisterRecorder(&m_render);
+        rive::cmd::unregisterRecorder(&m_ids);
+        // Drain cross thread destroys before the stream dies.
+        m_render.drainDestroys();
+    }
+
+    // make*: record a create and return a deferred object that records its own
+    // writes and destruction into the same stream.
+
+    rcp<Buffer> makeBuffer(const BufferDesc& desc) override
+    {
+        auto a = m_ids.alloc();
+        recordMakeBuffer(m_render, a.id, a.generation, desc);
+        return make_rcp<DeferredBuffer>(a.id,
+                                        a.generation,
+                                        &m_render,
+                                        &m_ids,
+                                        desc.size,
+                                        desc.usage);
+    }
+
+    rcp<Texture> makeTexture(const TextureDesc& desc) override
+    {
+        auto a = m_ids.alloc();
+        recordMakeTexture(m_render, a.id, a.generation, desc);
+        return make_rcp<DeferredTexture>(a.id,
+                                         a.generation,
+                                         &m_render,
+                                         &m_ids,
+                                         desc);
+    }
+
+    rcp<TextureView> makeTextureView(const TextureViewDesc& desc) override
+    {
+        auto a = m_ids.alloc();
+        recordMakeTextureView(m_render,
+                              a.id,
+                              a.generation,
+                              desc,
+                              handleFor(desc.texture));
+        return make_rcp<DeferredTextureView>(a.id,
+                                             a.generation,
+                                             &m_render,
+                                             &m_ids,
+                                             ref_rcp(desc.texture),
+                                             desc);
+    }
+
+    rcp<Sampler> makeSampler(const SamplerDesc& desc) override
+    {
+        auto a = m_ids.alloc();
+        recordMakeSampler(m_render, a.id, a.generation, desc);
+        return make_rcp<DeferredSampler>(a.id, a.generation, &m_render, &m_ids);
+    }
+
+    rcp<ShaderModule> makeShaderModule(const ShaderModuleDesc& desc) override
+    {
+        auto a = m_ids.alloc();
+        recordMakeShaderModule(m_render, a.id, a.generation, desc);
+        auto obj = make_rcp<DeferredShaderModule>(a.id,
+                                                  a.generation,
+                                                  &m_render,
+                                                  &m_ids);
+        // Parse the binding map so record time validation and layout
+        // derivation match the real backend.
+        if (desc.bindingMapBytes != nullptr && desc.bindingMapSize > 0)
+        {
+            obj->applyBindingMapFromDesc(desc);
+        }
+        return obj;
+    }
+
+    rcp<BindGroupLayout> makeBindGroupLayout(
+        const BindGroupLayoutDesc& desc) override
+    {
+        auto a = m_ids.alloc();
+        recordMakeBindGroupLayout(m_render, a.id, a.generation, desc);
+        return make_rcp<DeferredBindGroupLayout>(a.id,
+                                                 a.generation,
+                                                 &m_render,
+                                                 &m_ids);
+    }
+
+    rcp<Pipeline> makePipeline(const PipelineDesc& desc,
+                               std::string* /*outError*/ = nullptr) override
+    {
+        std::vector<ResourceHandle> bgls(desc.bindGroupLayoutCount);
+        for (uint32_t i = 0; i < desc.bindGroupLayoutCount; ++i)
+        {
+            bgls[i] = handleFor(desc.bindGroupLayouts[i]);
+        }
+        auto a = m_ids.alloc();
+        recordMakePipeline(
+            m_render,
+            a.id,
+            a.generation,
+            desc,
+            handleFor(desc.vertexModule),
+            handleFor(desc.fragmentModule),
+            Span<const ResourceHandle>(bgls.data(), bgls.size()));
+        return make_rcp<DeferredPipeline>(a.id,
+                                          a.generation,
+                                          &m_render,
+                                          &m_ids,
+                                          desc);
+    }
+
+    rcp<BindGroup> makeBindGroup(const BindGroupDesc& desc) override
+    {
+        std::vector<ResourceHandle> ubos(desc.uboCount),
+            texs(desc.textureCount), samps(desc.samplerCount);
+        for (uint32_t i = 0; i < desc.uboCount; ++i)
+        {
+            ubos[i] = handleFor(desc.ubos[i].buffer);
+        }
+        for (uint32_t i = 0; i < desc.textureCount; ++i)
+        {
+            texs[i] = handleFor(desc.textures[i].view);
+        }
+        for (uint32_t i = 0; i < desc.samplerCount; ++i)
+        {
+            samps[i] = handleFor(desc.samplers[i].sampler);
+        }
+        auto a = m_ids.alloc();
+        recordMakeBindGroup(
+            m_render,
+            a.id,
+            a.generation,
+            desc,
+            handleFor(desc.layout),
+            Span<const ResourceHandle>(ubos.data(), ubos.size()),
+            Span<const ResourceHandle>(texs.data(), texs.size()),
+            Span<const ResourceHandle>(samps.data(), samps.size()));
+        return make_rcp<DeferredBindGroup>(a.id,
+                                           a.generation,
+                                           &m_render,
+                                           &m_ids);
+    }
+
+    std::unique_ptr<RenderPass> beginRenderPass(
+        const RenderPassDesc& desc,
+        std::string* /*outError*/ = nullptr) override
+    {
+        return std::make_unique<RenderPassRecording>(this, &m_render, desc);
+    }
+
+    // Maps a canvas to its shared canvas id for replay. Set by the
+    // DeferredSession; when set wrapCanvasTexture never touches the device.
+    std::function<uint32_t(gpu::RenderCanvas*)> canvasIdProvider;
+
+    // Late binding for hosts whose real context outlives session creation.
+    void bindReal(Context* real)
+    {
+        bool late = m_real == nullptr && real != nullptr;
+        m_real = real;
+        adoptRealFeatures();
+        if (late)
+        {
+            checkUnboundAssumptions();
+        }
+    }
+
+    // A script must not branch on a capability this context cannot know. It
+    // knows one only once there is a real device to ask.
+    bool featuresKnown() const override { return m_real != nullptr; }
+
+    bool isRecording() const override { return true; }
+
+    // Maps a canvas backed image to its shared canvas id. Set by the owning
+    // session, null on sessionless GMs.
+    rive::cmd::ForeignImageRegistry* canvasRegistry = nullptr;
+
+    // Proxy view a reserved canvas returns at record time. The proxy format
+    // must match the real backing or checkPipelineCompat rejects every
+    // pipeline bound against it, and RenderPassRecording::setPipeline drops
+    // the command rather than appending it. Unbound there is nothing to ask,
+    // so the base class default stands and checkUnboundAssumptions fires if a
+    // late bound backend turns out to override it.
+    static constexpr TextureFormat kUnboundCanvasFormat =
+        TextureFormat::rgba8unorm;
+    rcp<TextureView> makeReservedCanvasView(ResourceHandle id,
+                                            uint32_t generation,
+                                            uint32_t width,
+                                            uint32_t height)
+    {
+        TextureDesc texDesc{};
+        texDesc.width = width;
+        texDesc.height = height;
+        texDesc.format = m_real != nullptr ? m_real->canvasTargetFormat()
+                                           : kUnboundCanvasFormat;
+        texDesc.type = TextureType::texture2D;
+        texDesc.renderTarget = true;
+        texDesc.numMipmaps = 1;
+        texDesc.sampleCount = 1;
+        auto proxyTex =
+            make_rcp<DeferredTexture>(0u, 0u, nullptr, nullptr, texDesc);
+        TextureViewDesc viewDesc{};
+        viewDesc.texture = proxyTex.get();
+        viewDesc.dimension = TextureViewDimension::texture2D;
+        viewDesc.baseMipLevel = 0;
+        viewDesc.mipCount = 1;
+        viewDesc.baseLayer = 0;
+        viewDesc.layerCount = 1;
+        return make_rcp<DeferredTextureView>(id,
+                                             generation,
+                                             &m_render,
+                                             &m_ids,
+                                             std::move(proxyTex),
+                                             viewDesc);
+    }
+
+    rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* c) override
+    {
+        if (!canvasIdProvider)
+        {
+            // Sessionless GM fallback: wrap the real host canvas directly.
+            assert(m_real != nullptr);
+            return m_real->wrapCanvasTexture(c);
+        }
+        // Reserve now; the consumer wraps at replay.
+        uint32_t canvasId = canvasIdProvider(c);
+        auto a = m_ids.alloc();
+        recordWrapCanvasView(m_render,
+                             a.id,
+                             a.generation,
+                             canvasId,
+                             WrapCanvasViewMode::colorView);
+        return makeReservedCanvasView(a.id,
+                                      a.generation,
+                                      c->width(),
+                                      c->height());
+    }
+
+    // Same reserve as wrapCanvasTexture but tagged sampleView so the consumer
+    // does the backend sampling wrap at replay.
+    rcp<TextureView> recordWrapCanvasImage(RenderImage* image,
+                                           uint32_t width,
+                                           uint32_t height) override
+    {
+        assert(canvasRegistry != nullptr);
+        uint32_t canvasId =
+            canvasRegistry->imageDrawId(image) & rive::cmd::kCanvasHandleMask;
+        auto a = m_ids.alloc();
+        recordWrapCanvasView(m_render,
+                             a.id,
+                             a.generation,
+                             canvasId,
+                             WrapCanvasViewMode::sampleView);
+        return makeReservedCanvasView(a.id, a.generation, width, height);
+    }
+
+    // Decoded image view: the consumer resolves the resident image and wraps
+    // its texture at replay.
+    rcp<TextureView> recordWrapImageView(uint32_t imageId,
+                                         uint32_t width,
+                                         uint32_t height) override
+    {
+        auto a = m_ids.alloc();
+        recordWrapCanvasView(m_render,
+                             a.id,
+                             a.generation,
+                             imageId,
+                             WrapCanvasViewMode::imageView);
+        return makeReservedCanvasView(a.id, a.generation, width, height);
+    }
+    rcp<TextureView> wrapRiveTexture(gpu::Texture* t,
+                                     uint32_t w,
+                                     uint32_t h) override
+    {
+        // Every script GPU op must be deferred. Hitting this while recording
+        // means a caller wraps a texture without its own reserve path.
+        fprintf(stderr,
+                "rive deferred: TRIPWIRE wrapRiveTexture hit immediately "
+                "during recording (a script GPU op is not deferred)\n");
+        assert(false && "wrapRiveTexture must be deferred while recording");
+        if (m_real == nullptr)
+        {
+            return nullptr;
+        }
+        return m_real->wrapRiveTexture(t, w, h);
+    }
+    // Selects the RSTB variant a script loads and records, so unlike features
+    // there is no way to refuse: returning nothing loads no shader at all.
+    // Web is the only host that binds late and web is GL, so the fallback is
+    // an assumption about one host rather than a guess about any device, and
+    // checkUnboundAssumptions fires if a late bind ever contradicts it.
+    static constexpr ShaderTarget kUnboundShaderTarget = ShaderTarget::glsl;
+    ShaderTarget shaderTarget() const override
+    {
+        return m_real != nullptr ? m_real->shaderTarget()
+                                 : kUnboundShaderTarget;
+    }
+
+    // No GPU at record time.
+
+    void beginFrame(const FrameDescriptor&) override {}
+    void endFrame() override {}
+    void waitForGPU() override {}
+
+    // Resident table the consumer persists across frames.
+    using RealTable = OreResident;
+
+    // Creates are idempotent, so a frame can replay repeatedly without
+    // recompiling. Persistent table for streaming, throwaway for single shot.
+    void replayFrame(Context& realCtx,
+                     RealTable& table,
+                     const OreCanvasResolve& canvasAt = {})
+    {
+        replayOreStream(
+            realCtx,
+            m_render,
+            table,
+            [this](ResourceHandle h) { return resolveReal(h); },
+            canvasAt);
+    }
+
+    // Single shot replay against a throwaway table, used by goldens.
+    void replay(Context& realCtx)
+    {
+        RealTable table;
+        replayFrame(realCtx, table);
+    }
+
+    struct StreamBytes
+    {
+        size_t commands, blobs;
+        size_t total() const { return commands + blobs; }
+    };
+    StreamBytes streamBytes() const
+    {
+        return {m_render.commandBytes().size(), m_render.blobBytes().size()};
+    }
+
+    // What this context references r by, the lookup every recorded cross
+    // reference resolves through. A deferred object of ours answers for
+    // itself: a live object cannot be stale about its own handle, so no
+    // address the allocator recycles can ever speak for the dead object that
+    // used to occupy it. Anything else is a real resource, retained by the
+    // frame and addressed by a flagged index.
+    ResourceHandle handleFor(Buffer* b)
+    {
+        return handleForAs<DeferredBuffer>(b);
+    }
+    ResourceHandle handleFor(Texture* t)
+    {
+        return handleForAs<DeferredTexture>(t);
+    }
+    ResourceHandle handleFor(TextureView* v)
+    {
+        return handleForAs<DeferredTextureView>(v);
+    }
+    ResourceHandle handleFor(Sampler* s)
+    {
+        return handleForAs<DeferredSampler>(s);
+    }
+    ResourceHandle handleFor(ShaderModule* m)
+    {
+        return handleForAs<DeferredShaderModule>(m);
+    }
+    ResourceHandle handleFor(BindGroupLayout* l)
+    {
+        return handleForAs<DeferredBindGroupLayout>(l);
+    }
+    ResourceHandle handleFor(Pipeline* p)
+    {
+        return handleForAs<DeferredPipeline>(p);
+    }
+    ResourceHandle handleFor(BindGroup* g)
+    {
+        return handleForAs<DeferredBindGroup>(g);
+    }
+
+    // The consumer keeps its resident table. Real bindings are re-captured
+    // each frame, keeping the retained set bounded by what one frame binds.
+    void resetFrame()
+    {
+        m_render.reset();
+        // Cross thread destroys drain on the recording thread, landing at the
+        // new frame's stream head.
+        m_render.drainDestroys();
+        m_realPtrToHandle.clear();
+        m_realResources.clear();
+    }
+
+    // Real bindings captured by this frame's stream, indexed by unflagged id.
+    const std::vector<rcp<rive::gpu::GPUResource>>& realResources() const
+    {
+        return m_realResources;
+    }
+
+    const OreCommandBuffer& stream() const { return m_render; }
+
+private:
+    // The replay device's capabilities are the ones a recording script has to
+    // see: it is the device the recorded branch will run on. Copied rather
+    // than forwarded so features() stays a non-virtual field read, and the
+    // copy cannot go stale because a backend measures its Features once, in
+    // its Make.
+    void adoptRealFeatures()
+    {
+        if (m_real != nullptr)
+        {
+            m_features = m_real->features();
+        }
+    }
+
+    // Everything answered before a late bind was answered without a device.
+    // features() refused rather than guessing, but the shader target and the
+    // canvas format had to answer something, and a script has already loaded
+    // and recorded against both. If the device that just arrived disagrees,
+    // the stream is already wrong in a way replay cannot detect: a mismatched
+    // canvas format makes checkPipelineCompat drop every setPipeline, leaving
+    // draws with no pipeline bound.
+    void checkUnboundAssumptions()
+    {
+        if (m_real->shaderTarget() != kUnboundShaderTarget)
+        {
+            fprintf(stderr,
+                    "rive deferred: TRIPWIRE late bound backend consumes "
+                    "shader target %u, but recording already loaded %u\n",
+                    static_cast<unsigned>(m_real->shaderTarget()),
+                    static_cast<unsigned>(kUnboundShaderTarget));
+            assert(false && "late bind changed the recorded shader target");
+        }
+        if (m_real->canvasTargetFormat() != kUnboundCanvasFormat)
+        {
+            fprintf(stderr,
+                    "rive deferred: TRIPWIRE late bound backend allocates "
+                    "canvases as format %u, but recording already reserved "
+                    "canvas views as %u\n",
+                    static_cast<unsigned>(m_real->canvasTargetFormat()),
+                    static_cast<unsigned>(kUnboundCanvasFormat));
+            assert(false && "late bind changed the recorded canvas format");
+        }
+    }
+
+    // Resolves a flagged real id to its retained real object.
+    rive::gpu::GPUResource* resolveReal(ResourceHandle h)
+    {
+        ResourceHandle i = h & kRealResourceMask;
+        return i < m_realResources.size() ? m_realResources[i].get() : nullptr;
+    }
+
+    // A deferred object reports its own handle, but only if it records into
+    // a stream this context writes: a reference is an index into the table
+    // that stream feeds, and a foreign one would name whatever this context
+    // happens to hold at that id. Those take the real path, as they did when
+    // this was a map lookup that simply failed to find them.
+    template <typename DeferredT, typename T> ResourceHandle handleForAs(T* r)
+    {
+        if (r == nullptr)
+        {
+            return kInvalidHandle;
+        }
+        if (auto* d = rive::lite_rtti_cast<DeferredT*>(r))
+        {
+            if (d->recordsInto(&m_render))
+            {
+                return d->clientHandle();
+            }
+        }
+        return realHandleFor(r);
+    }
+
+    // An already real resource gets a flagged id and is retained so it lives
+    // to replay.
+    ResourceHandle realHandleFor(rive::gpu::GPUResource* r)
+    {
+        if (r == nullptr)
+        {
+            return kInvalidHandle;
+        }
+        auto rit = m_realPtrToHandle.find(r);
+        if (rit != m_realPtrToHandle.end())
+        {
+            return rit->second;
+        }
+        // The unflagged index must fit under the flag bit.
+        assert(m_realResources.size() <= kRealResourceMask);
+        ResourceHandle h = kRealResourceFlag |
+                           static_cast<ResourceHandle>(m_realResources.size());
+        m_realResources.push_back(rive::ref_rcp(r));
+        m_realPtrToHandle.emplace(r, h);
+        return h;
+    }
+
+    Context* m_real;
+    OreCommandBuffer m_render; // the one ordered stream
+    // Reusable id space shared by every resource type.
+    rive::IdAllocator<ResourceHandle> m_ids;
+    // Already real resources this frame bound, retained until replay and
+    // addressed by flagged id. Cleared every resetFrame.
+    PtrHandleMap m_realPtrToHandle;
+    std::vector<rcp<rive::gpu::GPUResource>> m_realResources;
+};
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_deferred_render_pass.hpp b/renderer/include/rive/renderer/ore/cmd/ore_deferred_render_pass.hpp
new file mode 100644
index 0000000..b064025
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_deferred_render_pass.hpp
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp"
+#include "rive/renderer/ore/cmd/ore_replay.hpp"
+#include "rive/renderer/ore/ore_context.hpp"
+#include "rive/renderer/ore/ore_render_pass.hpp"
+#include <memory>
+
+// Single threaded record then replay inline. InlineDeferredRenderPass records
+// every call into an owned OreCommandBuffer and finish() drains it back
+// through the live immediate path, so output is byte identical to immediate
+// mode and callers need no changes.
+namespace rive::ore::cmd
+{
+
+// Orders the owned buffer before the recording base that writes into it.
+struct OwnedOreCommandBuffer
+{
+    OreCommandBuffer buffer;
+};
+
+class InlineDeferredRenderPass : private OwnedOreCommandBuffer,
+                                 public RenderPassRecording
+{
+public:
+    InlineDeferredRenderPass(Context* context, const RenderPassDesc& desc) :
+        RenderPassRecording(context, &buffer, desc)
+    {}
+
+    void finish() override
+    {
+        if (m_finished)
+        {
+            return;
+        }
+        // The base latches m_finished before we replay: replay reenters
+        // beginRenderPass, whose finishActiveRenderPass would otherwise call
+        // this again.
+        RenderPassRecording::finish();
+        replayCommandBuffer(*m_context, buffer);
+    }
+};
+
+// Single decision point between recording and the live immediate pass.
+inline std::unique_ptr<RenderPass> beginRenderPassRecordingOrImmediate(
+    Context& ctx,
+    const RenderPassDesc& desc,
+    std::string* outError = nullptr)
+{
+    if (ctx.deferredRecording())
+    {
+        if (ctx.usesDeferredFrameReplay())
+        {
+            // The backend replays the pending frame once at endFrame.
+            return std::make_unique<RenderPassRecording>(&ctx,
+                                                         &ctx.pendingFrame(),
+                                                         desc);
+        }
+        // No frame boundary drain on this backend, replay the pass inline.
+        return std::make_unique<InlineDeferredRenderPass>(&ctx, desc);
+    }
+    return ctx.beginRenderPass(desc, outError);
+}
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_deferred_resource.hpp b/renderer/include/rive/renderer/ore/cmd/ore_deferred_resource.hpp
new file mode 100644
index 0000000..2428f47
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_deferred_resource.hpp
@@ -0,0 +1,229 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/cmd/ore_handle.hpp"
+#include "rive/renderer/ore/cmd/ore_replay.hpp"
+#include "rive/renderer/ore/cmd/ore_make_recording.hpp"
+#include "rive/renderer/cmd/id_allocator.hpp"
+#include "rive/renderer/cmd/live_recorder_registry.hpp"
+#include "rive/renderer/ore/ore_buffer.hpp"
+#include "rive/renderer/ore/ore_texture.hpp"
+#include "rive/renderer/ore/ore_sampler.hpp"
+#include "rive/renderer/ore/ore_shader_module.hpp"
+#include "rive/renderer/ore/ore_bind_group_layout.hpp"
+#include "rive/renderer/ore/ore_pipeline.hpp"
+#include "rive/renderer/ore/ore_bind_group.hpp"
+#include <cassert>
+#include <functional>
+#include <unordered_map>
+#include <vector>
+
+// Client handle resource objects. A make* returns one immediately with no GPU
+// object, carrying its handle, generation, and the descriptor facts validation
+// needs. Replay creates the real object at the same handle; destruction
+// records a destroy and returns the id for reuse with a bumped generation.
+namespace rive::ore::cmd
+{
+
+// Pointer keyed handles for resources this recorder did not create: real
+// backend objects a frame binds, deduped so repeated binds cost one ref.
+// Deferred objects are never in here, they answer for themselves.
+using PtrHandleMap =
+    std::unordered_map<rive::gpu::GPUResource*, ResourceHandle>;
+
+// Common mixin. The DeferredOreContext owns the stream and allocator and
+// outlives every resource, so the back pointers stay valid.
+class DeferredResource
+{
+public:
+    DeferredResource(ResourceHandle handle,
+                     uint32_t generation,
+                     OreCommandBuffer* stream,
+                     rive::IdAllocator<ResourceHandle>* allocator) :
+        m_clientHandle(handle),
+        m_generation(generation),
+        m_stream(stream),
+        m_allocator(allocator)
+    {}
+    // Asking the live object is what makes a lookup immune to address
+    // recycling: an address only names this handle for as long as this object
+    // occupies it.
+    ResourceHandle clientHandle() const { return m_clientHandle; }
+
+    // A handle resolves against the table this resource's stream feeds, so a
+    // reader that writes a different stream must not use it.
+    bool recordsInto(const OreCommandBuffer* stream) const
+    {
+        return m_stream != nullptr && m_stream == stream;
+    }
+
+protected:
+    ~DeferredResource()
+    {
+        // Destructors run on any thread, possibly after the owning context
+        // died, so stragglers no-op and live destroys queue for the drain.
+        std::lock_guard<std::mutex> lock(rive::cmd::recorderRegistryMutex());
+        if (m_stream != nullptr)
+        {
+            if (rive::cmd::liveRecorders().count(m_stream) == 0)
+            {
+                return; // the context died first, nothing to record into
+            }
+            m_stream->queueDestroy({m_clientHandle, m_generation, m_allocator});
+            return;
+        }
+        if (m_allocator != nullptr &&
+            rive::cmd::liveRecorders().count(m_allocator) == 0)
+        {
+            return;
+        }
+        if (m_allocator != nullptr)
+        {
+            m_allocator->release(m_clientHandle, m_generation);
+        }
+    }
+    OreCommandBuffer* stream() const { return m_stream; }
+
+private:
+    ResourceHandle m_clientHandle;
+    uint32_t m_generation;
+    OreCommandBuffer* m_stream;
+    rive::IdAllocator<ResourceHandle>* m_allocator;
+};
+
+class DeferredBuffer : public LITE_RTTI_OVERRIDE(Buffer, DeferredBuffer),
+                       public DeferredResource
+{
+public:
+    DeferredBuffer(ResourceHandle handle,
+                   uint32_t generation,
+                   OreCommandBuffer* stream,
+                   rive::IdAllocator<ResourceHandle>* allocator,
+                   uint32_t size,
+                   BufferUsage usage) :
+        LITE_RTTI_OVERRIDE(Buffer, DeferredBuffer)(size, usage),
+        DeferredResource(handle, generation, stream, allocator)
+    {}
+
+    // Recorded, replayed on the real buffer before the passes.
+    void update(const void* data, uint32_t size, uint32_t offset) override
+    {
+        if (stream() != nullptr)
+        {
+            recordBufferUpdate(*stream(), clientHandle(), data, size, offset);
+        }
+    }
+};
+
+// The remaining types carry the descriptor so record time validation works
+// with no GPU object.
+
+class DeferredTexture : public LITE_RTTI_OVERRIDE(Texture, DeferredTexture),
+                        public DeferredResource
+{
+public:
+    DeferredTexture(ResourceHandle handle,
+                    uint32_t generation,
+                    OreCommandBuffer* stream,
+                    rive::IdAllocator<ResourceHandle>* allocator,
+                    const TextureDesc& desc) :
+        LITE_RTTI_OVERRIDE(Texture, DeferredTexture)(desc),
+        DeferredResource(handle, generation, stream, allocator)
+    {}
+
+    // Recorded, replayed on the real texture before the passes.
+    void upload(const TextureDataDesc& data) override
+    {
+        if (stream() != nullptr)
+        {
+            recordTextureUpload(*stream(), clientHandle(), data);
+        }
+    }
+};
+
+class DeferredTextureView
+    : public LITE_RTTI_OVERRIDE(TextureView, DeferredTextureView),
+      public DeferredResource
+{
+public:
+    DeferredTextureView(ResourceHandle handle,
+                        uint32_t generation,
+                        OreCommandBuffer* stream,
+                        rive::IdAllocator<ResourceHandle>* allocator,
+                        rcp<Texture> texture,
+                        const TextureViewDesc& desc) :
+        LITE_RTTI_OVERRIDE(TextureView, DeferredTextureView)(std::move(texture),
+                                                             desc),
+        DeferredResource(handle, generation, stream, allocator)
+    {}
+};
+
+class DeferredSampler : public LITE_RTTI_OVERRIDE(Sampler, DeferredSampler),
+                        public DeferredResource
+{
+public:
+    DeferredSampler(ResourceHandle handle,
+                    uint32_t generation,
+                    OreCommandBuffer* stream,
+                    rive::IdAllocator<ResourceHandle>* allocator) :
+        DeferredResource(handle, generation, stream, allocator)
+    {}
+};
+
+class DeferredShaderModule
+    : public LITE_RTTI_OVERRIDE(ShaderModule, DeferredShaderModule),
+      public DeferredResource
+{
+public:
+    DeferredShaderModule(ResourceHandle handle,
+                         uint32_t generation,
+                         OreCommandBuffer* stream,
+                         rive::IdAllocator<ResourceHandle>* allocator) :
+        DeferredResource(handle, generation, stream, allocator)
+    {}
+};
+
+class DeferredBindGroupLayout
+    : public LITE_RTTI_OVERRIDE(BindGroupLayout, DeferredBindGroupLayout),
+      public DeferredResource
+{
+public:
+    DeferredBindGroupLayout(ResourceHandle handle,
+                            uint32_t generation,
+                            OreCommandBuffer* stream,
+                            rive::IdAllocator<ResourceHandle>* allocator) :
+        DeferredResource(handle, generation, stream, allocator)
+    {}
+};
+
+class DeferredPipeline : public LITE_RTTI_OVERRIDE(Pipeline, DeferredPipeline),
+                         public DeferredResource
+{
+public:
+    DeferredPipeline(ResourceHandle handle,
+                     uint32_t generation,
+                     OreCommandBuffer* stream,
+                     rive::IdAllocator<ResourceHandle>* allocator,
+                     const PipelineDesc& desc) :
+        LITE_RTTI_OVERRIDE(Pipeline, DeferredPipeline)(desc),
+        DeferredResource(handle, generation, stream, allocator)
+    {}
+};
+
+class DeferredBindGroup
+    : public LITE_RTTI_OVERRIDE(BindGroup, DeferredBindGroup),
+      public DeferredResource
+{
+public:
+    DeferredBindGroup(ResourceHandle handle,
+                      uint32_t generation,
+                      OreCommandBuffer* stream,
+                      rive::IdAllocator<ResourceHandle>* allocator) :
+        DeferredResource(handle, generation, stream, allocator)
+    {}
+};
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_handle.hpp b/renderer/include/rive/renderer/ore/cmd/ore_handle.hpp
new file mode 100644
index 0000000..dd28c4e
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_handle.hpp
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/handle_flags.hpp"
+#include <cstdint>
+
+// Shared handle type for the deferred command streams. A render command
+// handle indexes the OreCommandBuffer keep alive table; a resource command
+// handle is the client id of a deferred created resource.
+namespace rive::ore::cmd
+{
+using ResourceHandle = uint32_t;
+constexpr ResourceHandle kInvalidHandle = ~0u;
+
+// Flags a resource that already exists at record time and so is not in the
+// creation stream; the low bits index a side table of the real objects. Test
+// after kInvalidHandle, which also has the high bit set.
+constexpr ResourceHandle kRealResourceFlag = rive::cmd::kHandleForeignFlag;
+constexpr ResourceHandle kRealResourceMask = rive::cmd::kHandleForeignMask;
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_make_recording.hpp b/renderer/include/rive/renderer/ore/cmd/ore_make_recording.hpp
new file mode 100644
index 0000000..44470c5
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_make_recording.hpp
@@ -0,0 +1,302 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_commands.hpp"
+#include "rive/renderer/ore/ore_types.hpp"
+#include <vector>
+
+// Records the make* family into the ordered Ore command stream. The caller
+// owns id allocation so id and generation ride explicitly in the header.
+// Replay lives in ore_make_replay.hpp.
+namespace rive::ore::cmd
+{
+
+inline void recordMakeBuffer(OreCommandBuffer& cb,
+                             ResourceHandle id,
+                             uint32_t generation,
+                             const BufferDesc& desc)
+{
+    BufferDescPOD pod{};
+    pod.usage = desc.usage;
+    pod.size = desc.size;
+    pod.immutable = desc.immutable;
+    pod.data =
+        cb.appendBlobRef(desc.data, desc.data ? desc.size : 0, !desc.data);
+    pod.label = cb.appendStringRef(desc.label);
+    cb.append(CommandType::makeBuffer, MakeResourcePOD{id, generation});
+    cb.appendPayload(pod);
+}
+
+inline void recordMakeTexture(OreCommandBuffer& cb,
+                              ResourceHandle id,
+                              uint32_t generation,
+                              const TextureDesc& desc)
+{
+    TextureDescPOD pod{};
+    pod.width = desc.width;
+    pod.height = desc.height;
+    pod.depthOrArrayLayers = desc.depthOrArrayLayers;
+    pod.format = desc.format;
+    pod.type = desc.type;
+    pod.renderTarget = desc.renderTarget;
+    pod.numMipmaps = desc.numMipmaps;
+    pod.sampleCount = desc.sampleCount;
+    pod.label = cb.appendStringRef(desc.label);
+    cb.append(CommandType::makeTexture, MakeResourcePOD{id, generation});
+    cb.appendPayload(pod);
+}
+
+inline void recordMakeSampler(OreCommandBuffer& cb,
+                              ResourceHandle id,
+                              uint32_t generation,
+                              const SamplerDesc& desc)
+{
+    SamplerDescPOD pod{};
+    pod.minFilter = desc.minFilter;
+    pod.magFilter = desc.magFilter;
+    pod.mipmapFilter = desc.mipmapFilter;
+    pod.wrapU = desc.wrapU;
+    pod.wrapV = desc.wrapV;
+    pod.wrapW = desc.wrapW;
+    pod.compare = desc.compare;
+    pod.minLod = desc.minLod;
+    pod.maxLod = desc.maxLod;
+    pod.maxAnisotropy = desc.maxAnisotropy;
+    pod.label = cb.appendStringRef(desc.label);
+    cb.append(CommandType::makeSampler, MakeResourcePOD{id, generation});
+    cb.appendPayload(pod);
+}
+
+inline void recordMakeShaderModule(OreCommandBuffer& cb,
+                                   ResourceHandle id,
+                                   uint32_t generation,
+                                   const ShaderModuleDesc& desc)
+{
+    ShaderModuleDescPOD pod{};
+    pod.code = cb.appendBlobRef(desc.code, desc.codeSize, !desc.code);
+    pod.language = desc.language;
+    pod.stage = desc.stage;
+    pod.label = cb.appendStringRef(desc.label);
+    pod.hlslSource = cb.appendBlobRef(desc.hlslSource,
+                                      desc.hlslSourceSize,
+                                      !desc.hlslSource);
+    pod.hlslEntryPoint = cb.appendStringRef(desc.hlslEntryPoint);
+    pod.bindingMapBytes = cb.appendBlobRef(desc.bindingMapBytes,
+                                           desc.bindingMapSize,
+                                           !desc.bindingMapBytes);
+    pod.glFixupBytes = cb.appendBlobRef(desc.glFixupBytes,
+                                        desc.glFixupSize,
+                                        !desc.glFixupBytes);
+    pod.shaderAssetId = desc.shaderAssetId;
+    cb.append(CommandType::makeShaderModule, MakeResourcePOD{id, generation});
+    cb.appendPayload(pod);
+}
+
+inline void recordMakeBindGroupLayout(OreCommandBuffer& cb,
+                                      ResourceHandle id,
+                                      uint32_t generation,
+                                      const BindGroupLayoutDesc& desc)
+{
+    BindGroupLayoutDescPOD pod{};
+    pod.groupIndex = desc.groupIndex;
+    pod.entryCount = desc.entryCount;
+    pod.entries =
+        cb.appendBlobRef(desc.entries,
+                         desc.entryCount * sizeof(BindGroupLayoutEntry),
+                         desc.entries == nullptr);
+    pod.label = cb.appendStringRef(desc.label);
+    cb.append(CommandType::makeBindGroupLayout,
+              MakeResourcePOD{id, generation});
+    cb.appendPayload(pod);
+}
+
+inline void recordMakeTextureView(OreCommandBuffer& cb,
+                                  ResourceHandle id,
+                                  uint32_t generation,
+                                  const TextureViewDesc& desc,
+                                  ResourceHandle textureHandle)
+{
+    TextureViewDescPOD pod{};
+    pod.texture = textureHandle;
+    pod.dimension = desc.dimension;
+    pod.aspect = desc.aspect;
+    pod.baseMipLevel = desc.baseMipLevel;
+    pod.mipCount = desc.mipCount;
+    pod.baseLayer = desc.baseLayer;
+    pod.layerCount = desc.layerCount;
+    cb.append(CommandType::makeTextureView, MakeResourcePOD{id, generation});
+    cb.appendPayload(pod);
+}
+
+inline void recordMakePipeline(OreCommandBuffer& cb,
+                               ResourceHandle id,
+                               uint32_t generation,
+                               const PipelineDesc& desc,
+                               ResourceHandle vertexModule,
+                               ResourceHandle fragmentModule,
+                               Span<const ResourceHandle> bindGroupLayouts)
+{
+    std::vector<VertexBufferLayoutPOD> vbPods(desc.vertexBufferCount);
+    for (uint32_t i = 0; i < desc.vertexBufferCount; ++i)
+    {
+        const VertexBufferLayout& vb = desc.vertexBuffers[i];
+        vbPods[i].stride = vb.stride;
+        vbPods[i].stepMode = vb.stepMode;
+        vbPods[i].attributeCount = vb.attributeCount;
+        vbPods[i].attributes =
+            cb.appendBlobRef(vb.attributes,
+                             vb.attributeCount * sizeof(VertexAttribute),
+                             vb.attributes == nullptr);
+    }
+
+    PipelineDescPOD pod{};
+    pod.vertexModule = vertexModule;
+    pod.vertexEntryPoint = cb.appendStringRef(desc.vertexEntryPoint);
+    pod.fragmentModule = fragmentModule;
+    pod.fragmentEntryPoint = cb.appendStringRef(desc.fragmentEntryPoint);
+    pod.vertexBufferCount = desc.vertexBufferCount;
+    pod.vertexBuffers = cb.appendBlobRef(
+        vbPods.data(),
+        static_cast<uint32_t>(vbPods.size() * sizeof(VertexBufferLayoutPOD)),
+        vbPods.empty());
+    pod.topology = desc.topology;
+    pod.indexFormat = desc.indexFormat;
+    pod.cullMode = desc.cullMode;
+    pod.winding = desc.winding;
+    for (uint32_t i = 0; i < 4; ++i)
+    {
+        pod.colorTargets[i] = desc.colorTargets[i];
+    }
+    pod.colorCount = desc.colorCount;
+    pod.depthStencil = desc.depthStencil;
+    pod.stencilFront = desc.stencilFront;
+    pod.stencilBack = desc.stencilBack;
+    pod.stencilReadMask = desc.stencilReadMask;
+    pod.stencilWriteMask = desc.stencilWriteMask;
+    pod.sampleCount = desc.sampleCount;
+    pod.bindGroupLayoutCount = static_cast<uint32_t>(bindGroupLayouts.size());
+    pod.bindGroupLayouts = cb.appendBlobRef(
+        bindGroupLayouts.data(),
+        static_cast<uint32_t>(bindGroupLayouts.size() * sizeof(ResourceHandle)),
+        bindGroupLayouts.empty());
+    pod.label = cb.appendStringRef(desc.label);
+    cb.append(CommandType::makePipeline, MakeResourcePOD{id, generation});
+    cb.appendPayload(pod);
+}
+
+inline void recordMakeBindGroup(OreCommandBuffer& cb,
+                                ResourceHandle id,
+                                uint32_t generation,
+                                const BindGroupDesc& desc,
+                                ResourceHandle layout,
+                                Span<const ResourceHandle> uboBuffers,
+                                Span<const ResourceHandle> texViews,
+                                Span<const ResourceHandle> sampSamplers)
+{
+    std::vector<UBOEntryPOD> ubos(desc.uboCount);
+    for (uint32_t i = 0; i < desc.uboCount; ++i)
+    {
+        ubos[i].slot = desc.ubos[i].slot;
+        ubos[i].buffer = i < uboBuffers.size() ? uboBuffers[i] : kInvalidHandle;
+        ubos[i].offset = desc.ubos[i].offset;
+        ubos[i].size = desc.ubos[i].size;
+    }
+    std::vector<TexEntryPOD> texs(desc.textureCount);
+    for (uint32_t i = 0; i < desc.textureCount; ++i)
+    {
+        texs[i].slot = desc.textures[i].slot;
+        texs[i].view = i < texViews.size() ? texViews[i] : kInvalidHandle;
+    }
+    std::vector<SampEntryPOD> samps(desc.samplerCount);
+    for (uint32_t i = 0; i < desc.samplerCount; ++i)
+    {
+        samps[i].slot = desc.samplers[i].slot;
+        samps[i].sampler =
+            i < sampSamplers.size() ? sampSamplers[i] : kInvalidHandle;
+    }
+
+    BindGroupDescPOD pod{};
+    pod.layout = layout;
+    pod.uboCount = desc.uboCount;
+    pod.ubos = cb.appendBlobRef(
+        ubos.data(),
+        static_cast<uint32_t>(ubos.size() * sizeof(UBOEntryPOD)),
+        ubos.empty());
+    pod.textureCount = desc.textureCount;
+    pod.textures = cb.appendBlobRef(
+        texs.data(),
+        static_cast<uint32_t>(texs.size() * sizeof(TexEntryPOD)),
+        texs.empty());
+    pod.samplerCount = desc.samplerCount;
+    pod.samplers = cb.appendBlobRef(
+        samps.data(),
+        static_cast<uint32_t>(samps.size() * sizeof(SampEntryPOD)),
+        samps.empty());
+    pod.label = cb.appendStringRef(desc.label);
+    cb.append(CommandType::makeBindGroup, MakeResourcePOD{id, generation});
+    cb.appendPayload(pod);
+}
+
+inline void recordBufferUpdate(OreCommandBuffer& cb,
+                               ResourceHandle handle,
+                               const void* data,
+                               uint32_t size,
+                               uint32_t offset)
+{
+    BufferUpdatePOD pod{};
+    pod.handle = handle;
+    pod.offset = offset;
+    pod.bytes = cb.appendBlobRef(data, size, data == nullptr);
+    cb.append(CommandType::bufferUpdate, pod);
+}
+
+inline void recordTextureUpload(OreCommandBuffer& cb,
+                                ResourceHandle handle,
+                                const TextureDataDesc& desc)
+{
+    uint32_t rows = desc.rowsPerImage ? desc.rowsPerImage : desc.height;
+    uint32_t size = desc.bytesPerRow * rows;
+    TextureUploadPOD pod{};
+    pod.handle = handle;
+    pod.bytesPerRow = desc.bytesPerRow;
+    pod.rowsPerImage = desc.rowsPerImage;
+    pod.mipLevel = desc.mipLevel;
+    pod.layer = desc.layer;
+    pod.x = desc.x;
+    pod.y = desc.y;
+    pod.z = desc.z;
+    pod.width = desc.width;
+    pod.height = desc.height;
+    pod.depth = desc.depth;
+    pod.bytes =
+        cb.appendBlobRef(desc.data, size, desc.data == nullptr || size == 0);
+    cb.append(CommandType::textureUpload, pod);
+}
+
+inline void recordWrapCanvasView(
+    OreCommandBuffer& cb,
+    ResourceHandle id,
+    uint32_t generation,
+    uint32_t canvasId,
+    WrapCanvasViewMode mode = WrapCanvasViewMode::colorView)
+{
+    cb.append(CommandType::wrapCanvasView,
+              WrapCanvasViewPOD{id,
+                                generation,
+                                canvasId,
+                                static_cast<uint32_t>(mode)});
+}
+
+inline void recordDestroyResource(OreCommandBuffer& cb,
+                                  ResourceHandle handle,
+                                  uint32_t generation)
+{
+    cb.append(CommandType::destroyResource,
+              DestroyResourcePOD{handle, generation});
+}
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp b/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp
new file mode 100644
index 0000000..a1f29c7
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp
@@ -0,0 +1,559 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_commands.hpp"
+#include "rive/renderer/ore/ore_context.hpp"
+#include "rive/renderer/rive_render_image.hpp"
+#include "rive/renderer/ore/ore_buffer.hpp"
+#include "rive/renderer/ore/ore_texture.hpp"
+#include <cassert>
+#include <functional>
+#include <vector>
+
+// Consumer half of the ordered ore stream: the resident table plus the
+// lifecycle replay arms. Pass arms live in ore_replay.hpp. The table is dense
+// and bounded by the live high water mark; a stale destroy for a recycled id
+// is dropped by the generation check.
+namespace rive::ore::cmd
+{
+
+// Slot type tag: one allocator serves every resource type, so an id race
+// under churn can put a differently typed object in a referenced slot. A
+// kind checked lookup turns that into an unresolved dependency instead of a
+// garbage cast.
+enum class OreKind : uint8_t
+{
+    none,
+    buffer,
+    texture,
+    textureView,
+    sampler,
+    shaderModule,
+    bindGroupLayout,
+    pipeline,
+    bindGroup,
+};
+
+struct OreResident
+{
+    std::vector<rcp<rive::gpu::GPUResource>> objects;
+    std::vector<uint32_t> generations;
+    std::vector<OreKind> kinds;
+
+    void set(ResourceHandle id,
+             rcp<rive::gpu::GPUResource> obj,
+             uint32_t generation,
+             OreKind kind)
+    {
+        if (id > objects.size())
+        {
+            // The producer mints ids sequentially, so a fresh id may only
+            // append. Anything further ahead is a corrupt stream.
+            assert(false);
+            return;
+        }
+        if (id == objects.size())
+        {
+            objects.push_back(std::move(obj));
+            generations.push_back(generation);
+            kinds.push_back(kind);
+            return;
+        }
+        objects[id] = std::move(obj);
+        generations[id] = generation;
+        kinds[id] = kind;
+    }
+    void destroy(ResourceHandle id, uint32_t generation)
+    {
+        if (id < objects.size() && generations[id] == generation)
+        {
+            objects[id] = nullptr;
+        }
+    }
+    rive::gpu::GPUResource* get(ResourceHandle id) const
+    {
+        return id < objects.size() ? objects[id].get() : nullptr;
+    }
+    rive::gpu::GPUResource* getAs(ResourceHandle id, OreKind kind) const
+    {
+        return id < objects.size() && kinds[id] == kind ? objects[id].get()
+                                                        : nullptr;
+    }
+    // Make replay skips live slots, so repeated replays do not recompile.
+    bool alive(ResourceHandle id, uint32_t generation) const
+    {
+        return id < objects.size() && objects[id] != nullptr &&
+               generations[id] == generation;
+    }
+};
+
+// A flagged id is an already real resource; any other id indexes the resident
+// table.
+using OreHandleResolve = std::function<rive::gpu::GPUResource*(ResourceHandle)>;
+
+// Kind checked resolve used by makes and passes; real flagged ids stay
+// unchecked since the real side table is typed by construction.
+using OreKindResolve =
+    std::function<rive::gpu::GPUResource*(ResourceHandle, OreKind)>;
+
+// Resolves a shared canvas id to its real RenderCanvas at replay.
+using OreCanvasResolve = std::function<rive::gpu::RenderCanvas*(uint32_t)>;
+
+// Resolves a 2D image id to its resident RenderImage at replay.
+using OreImageResolve = std::function<rive::RenderImage*(uint32_t)>;
+
+// Resolve a stream reference: a real flagged id hits the caller's side table,
+// a bare id the resident table.
+inline rive::gpu::GPUResource* resolveOre(const OreResident& session,
+                                          const OreHandleResolve& real,
+                                          ResourceHandle h,
+                                          OreKind kind)
+{
+    if (h == kInvalidHandle)
+    {
+        return nullptr;
+    }
+    if (h & kRealResourceFlag)
+    {
+        return real ? real(h) : nullptr;
+    }
+    return session.getAs(h, kind);
+}
+
+// Returns false for a pass command so the caller's pass switch takes it.
+inline bool replayOreLifecycle(Context& ctx,
+                               OreResident& table,
+                               CommandType type,
+                               OreCommandReader& reader,
+                               const OreKindResolve& resolve,
+                               const OreCanvasResolve& canvasAt = {},
+                               const OreImageResolve& imageAt = {})
+{
+    auto blob = [&](BlobRef ref) -> Span<const uint8_t> {
+        return ref.absent() ? Span<const uint8_t>(nullptr, 0)
+                            : reader.blobAt(ref.offset, ref.size);
+    };
+    auto cstr = [&](BlobRef ref) -> const char* {
+        return ref.absent() ? nullptr
+                            : reinterpret_cast<const char*>(blob(ref).data());
+    };
+    auto bytesOf = [&](BlobRef ref) -> const void* {
+        return ref.absent() ? nullptr : blob(ref).data();
+    };
+
+    // A dependency that should resolve but comes back null was churned under a
+    // straddling frame. Skip the make since a null crashes some backends.
+    bool unresolvedDep = false;
+    auto req = [&](ResourceHandle h, OreKind kind) -> rive::gpu::GPUResource* {
+        auto* r = resolve(h, kind);
+        if (r == nullptr && h != kInvalidHandle)
+        {
+            unresolvedDep = true;
+        }
+        return r;
+    };
+    // The null slot keeps the dense table aligned with minted ids; skipping
+    // the set would discard every later make behind the hole.
+    auto skipUnresolvedMake =
+        [&](ResourceHandle id, uint32_t generation, const char* what) -> bool {
+        RIVE_WARN_THROTTLED("rive ore replay: skip make %s id=%u gen=%u "
+                            "(unresolved dep, churn)\n",
+                            what,
+                            id,
+                            generation);
+        table.set(id, nullptr, generation, OreKind::none);
+        return true; // empty slot, downstream draws drop
+    };
+
+    switch (type)
+    {
+        case CommandType::makeBuffer:
+        {
+            auto m = reader.read<MakeResourcePOD>();
+            auto pod = reader.read<BufferDescPOD>();
+            if (table.alive(m.id, m.generation))
+            {
+                return true;
+            }
+            BufferDesc d{};
+            d.usage = pod.usage;
+            d.size = pod.size;
+            d.immutable = pod.immutable;
+            d.data = bytesOf(pod.data);
+            d.label = cstr(pod.label);
+            table.set(m.id, ctx.makeBuffer(d), m.generation, OreKind::buffer);
+            return true;
+        }
+        case CommandType::makeTexture:
+        {
+            auto m = reader.read<MakeResourcePOD>();
+            auto pod = reader.read<TextureDescPOD>();
+            if (table.alive(m.id, m.generation))
+            {
+                return true;
+            }
+            TextureDesc d{};
+            d.width = pod.width;
+            d.height = pod.height;
+            d.depthOrArrayLayers = pod.depthOrArrayLayers;
+            d.format = pod.format;
+            d.type = pod.type;
+            d.renderTarget = pod.renderTarget;
+            d.numMipmaps = pod.numMipmaps;
+            d.sampleCount = pod.sampleCount;
+            d.label = cstr(pod.label);
+            table.set(m.id, ctx.makeTexture(d), m.generation, OreKind::texture);
+            return true;
+        }
+        case CommandType::makeSampler:
+        {
+            auto m = reader.read<MakeResourcePOD>();
+            auto pod = reader.read<SamplerDescPOD>();
+            if (table.alive(m.id, m.generation))
+            {
+                return true;
+            }
+            SamplerDesc d{};
+            d.minFilter = pod.minFilter;
+            d.magFilter = pod.magFilter;
+            d.mipmapFilter = pod.mipmapFilter;
+            d.wrapU = pod.wrapU;
+            d.wrapV = pod.wrapV;
+            d.wrapW = pod.wrapW;
+            d.compare = pod.compare;
+            d.minLod = pod.minLod;
+            d.maxLod = pod.maxLod;
+            d.maxAnisotropy = pod.maxAnisotropy;
+            d.label = cstr(pod.label);
+            table.set(m.id, ctx.makeSampler(d), m.generation, OreKind::sampler);
+            return true;
+        }
+        case CommandType::makeShaderModule:
+        {
+            auto m = reader.read<MakeResourcePOD>();
+            auto pod = reader.read<ShaderModuleDescPOD>();
+            if (table.alive(m.id, m.generation))
+            {
+                return true;
+            }
+            ShaderModuleDesc d{};
+            d.code = bytesOf(pod.code);
+            d.codeSize = static_cast<uint32_t>(blob(pod.code).size());
+            d.language = pod.language;
+            d.stage = pod.stage;
+            d.label = cstr(pod.label);
+            d.hlslSource = cstr(pod.hlslSource);
+            d.hlslSourceSize =
+                static_cast<uint32_t>(blob(pod.hlslSource).size());
+            d.hlslEntryPoint = cstr(pod.hlslEntryPoint);
+            d.bindingMapBytes =
+                static_cast<const uint8_t*>(bytesOf(pod.bindingMapBytes));
+            d.bindingMapSize =
+                static_cast<uint32_t>(blob(pod.bindingMapBytes).size());
+            d.glFixupBytes =
+                static_cast<const uint8_t*>(bytesOf(pod.glFixupBytes));
+            d.glFixupSize =
+                static_cast<uint32_t>(blob(pod.glFixupBytes).size());
+            d.shaderAssetId = pod.shaderAssetId;
+            table.set(m.id,
+                      ctx.makeShaderModule(d),
+                      m.generation,
+                      OreKind::shaderModule);
+            return true;
+        }
+        case CommandType::makeBindGroupLayout:
+        {
+            auto m = reader.read<MakeResourcePOD>();
+            auto pod = reader.read<BindGroupLayoutDescPOD>();
+            if (table.alive(m.id, m.generation))
+            {
+                return true;
+            }
+            BindGroupLayoutDesc d{};
+            d.groupIndex = pod.groupIndex;
+            d.entryCount = pod.entryCount;
+            d.entries = reinterpret_cast<const BindGroupLayoutEntry*>(
+                bytesOf(pod.entries));
+            d.label = cstr(pod.label);
+            table.set(m.id,
+                      ctx.makeBindGroupLayout(d),
+                      m.generation,
+                      OreKind::bindGroupLayout);
+            return true;
+        }
+        case CommandType::makeTextureView:
+        {
+            auto m = reader.read<MakeResourcePOD>();
+            auto pod = reader.read<TextureViewDescPOD>();
+            if (table.alive(m.id, m.generation))
+            {
+                return true;
+            }
+            TextureViewDesc d{};
+            d.texture =
+                static_cast<Texture*>(req(pod.texture, OreKind::texture));
+            d.dimension = pod.dimension;
+            d.aspect = pod.aspect;
+            d.baseMipLevel = pod.baseMipLevel;
+            d.mipCount = pod.mipCount;
+            d.baseLayer = pod.baseLayer;
+            d.layerCount = pod.layerCount;
+            if (unresolvedDep)
+                return skipUnresolvedMake(m.id, m.generation, "textureView");
+            table.set(m.id,
+                      ctx.makeTextureView(d),
+                      m.generation,
+                      OreKind::textureView);
+            return true;
+        }
+        case CommandType::makePipeline:
+        {
+            auto m = reader.read<MakeResourcePOD>();
+            auto pod = reader.read<PipelineDescPOD>();
+            if (table.alive(m.id, m.generation))
+            {
+                return true;
+            }
+            Span<const uint8_t> vbBlob = blob(pod.vertexBuffers);
+            const VertexBufferLayoutPOD* vbPods =
+                reinterpret_cast<const VertexBufferLayoutPOD*>(vbBlob.data());
+            std::vector<VertexBufferLayout> vbs(pod.vertexBufferCount);
+            for (uint32_t i = 0; i < pod.vertexBufferCount; ++i)
+            {
+                vbs[i].stride = vbPods[i].stride;
+                vbs[i].stepMode = vbPods[i].stepMode;
+                vbs[i].attributeCount = vbPods[i].attributeCount;
+                vbs[i].attributes = reinterpret_cast<const VertexAttribute*>(
+                    blob(vbPods[i].attributes).data());
+            }
+            Span<const uint8_t> bglBlob = blob(pod.bindGroupLayouts);
+            const ResourceHandle* bglHandles =
+                reinterpret_cast<const ResourceHandle*>(bglBlob.data());
+            std::vector<BindGroupLayout*> bgls(pod.bindGroupLayoutCount);
+            for (uint32_t i = 0; i < pod.bindGroupLayoutCount; ++i)
+            {
+                bgls[i] = static_cast<BindGroupLayout*>(
+                    req(bglHandles[i], OreKind::bindGroupLayout));
+            }
+
+            PipelineDesc d{};
+            d.vertexModule = static_cast<ShaderModule*>(
+                req(pod.vertexModule, OreKind::shaderModule));
+            d.vertexEntryPoint = cstr(pod.vertexEntryPoint);
+            d.fragmentModule = static_cast<ShaderModule*>(
+                req(pod.fragmentModule, OreKind::shaderModule));
+            d.fragmentEntryPoint = cstr(pod.fragmentEntryPoint);
+            d.vertexBuffers = vbs.empty() ? nullptr : vbs.data();
+            d.vertexBufferCount = pod.vertexBufferCount;
+            d.topology = pod.topology;
+            d.indexFormat = pod.indexFormat;
+            d.cullMode = pod.cullMode;
+            d.winding = pod.winding;
+            for (uint32_t i = 0; i < 4; ++i)
+            {
+                d.colorTargets[i] = pod.colorTargets[i];
+            }
+            d.colorCount = pod.colorCount;
+            d.depthStencil = pod.depthStencil;
+            d.stencilFront = pod.stencilFront;
+            d.stencilBack = pod.stencilBack;
+            d.stencilReadMask = pod.stencilReadMask;
+            d.stencilWriteMask = pod.stencilWriteMask;
+            d.sampleCount = pod.sampleCount;
+            d.bindGroupLayouts = bgls.empty() ? nullptr : bgls.data();
+            d.bindGroupLayoutCount = pod.bindGroupLayoutCount;
+            d.label = cstr(pod.label);
+            if (unresolvedDep)
+                return skipUnresolvedMake(m.id, m.generation, "pipeline");
+            std::string pipelineError;
+            auto realPipeline = ctx.makePipeline(d, &pipelineError);
+            if (realPipeline == nullptr)
+            {
+                RIVE_WARN_THROTTLED(
+                    "rive ore replay: makePipeline id=%u gen=%u failed: %s\n",
+                    m.id,
+                    m.generation,
+                    pipelineError.c_str());
+            }
+            table.set(m.id,
+                      std::move(realPipeline),
+                      m.generation,
+                      OreKind::pipeline);
+            return true;
+        }
+        case CommandType::makeBindGroup:
+        {
+            auto m = reader.read<MakeResourcePOD>();
+            auto pod = reader.read<BindGroupDescPOD>();
+            if (table.alive(m.id, m.generation))
+            {
+                return true;
+            }
+            const UBOEntryPOD* uboPods =
+                reinterpret_cast<const UBOEntryPOD*>(blob(pod.ubos).data());
+            std::vector<BindGroupDesc::UBOEntry> ubos(pod.uboCount);
+            for (uint32_t i = 0; i < pod.uboCount; ++i)
+            {
+                ubos[i].slot = uboPods[i].slot;
+                ubos[i].buffer = static_cast<Buffer*>(
+                    req(uboPods[i].buffer, OreKind::buffer));
+                ubos[i].offset = uboPods[i].offset;
+                ubos[i].size = uboPods[i].size;
+            }
+            const TexEntryPOD* texPods =
+                reinterpret_cast<const TexEntryPOD*>(blob(pod.textures).data());
+            std::vector<BindGroupDesc::TexEntry> texs(pod.textureCount);
+            for (uint32_t i = 0; i < pod.textureCount; ++i)
+            {
+                texs[i].slot = texPods[i].slot;
+                texs[i].view = static_cast<TextureView*>(
+                    req(texPods[i].view, OreKind::textureView));
+            }
+            const SampEntryPOD* sampPods =
+                reinterpret_cast<const SampEntryPOD*>(
+                    blob(pod.samplers).data());
+            std::vector<BindGroupDesc::SampEntry> samps(pod.samplerCount);
+            for (uint32_t i = 0; i < pod.samplerCount; ++i)
+            {
+                samps[i].slot = sampPods[i].slot;
+                samps[i].sampler = static_cast<Sampler*>(
+                    req(sampPods[i].sampler, OreKind::sampler));
+            }
+
+            BindGroupDesc d{};
+            d.layout = static_cast<BindGroupLayout*>(
+                req(pod.layout, OreKind::bindGroupLayout));
+            d.ubos = ubos.empty() ? nullptr : ubos.data();
+            d.uboCount = pod.uboCount;
+            d.textures = texs.empty() ? nullptr : texs.data();
+            d.textureCount = pod.textureCount;
+            d.samplers = samps.empty() ? nullptr : samps.data();
+            d.samplerCount = pod.samplerCount;
+            d.label = cstr(pod.label);
+            if (unresolvedDep)
+                return skipUnresolvedMake(m.id, m.generation, "bindGroup");
+            auto realBindGroup = ctx.makeBindGroup(d);
+            if (realBindGroup == nullptr)
+            {
+                RIVE_WARN_THROTTLED(
+                    "rive ore replay: makeBindGroup id=%u gen=%u returned "
+                    "null\n",
+                    m.id,
+                    m.generation);
+            }
+            table.set(m.id,
+                      std::move(realBindGroup),
+                      m.generation,
+                      OreKind::bindGroup);
+            return true;
+        }
+        case CommandType::bufferUpdate:
+        {
+            auto pod = reader.read<BufferUpdatePOD>();
+            Span<const uint8_t> b = blob(pod.bytes);
+            if (auto* buf = static_cast<Buffer*>(table.get(pod.handle)))
+            {
+                buf->update(b.data(),
+                            static_cast<uint32_t>(b.size()),
+                            pod.offset);
+            }
+            return true;
+        }
+        case CommandType::textureUpload:
+        {
+            auto pod = reader.read<TextureUploadPOD>();
+            Span<const uint8_t> b = blob(pod.bytes);
+            TextureDataDesc d{};
+            d.data = b.empty() ? nullptr : b.data();
+            d.bytesPerRow = pod.bytesPerRow;
+            d.rowsPerImage = pod.rowsPerImage;
+            d.mipLevel = pod.mipLevel;
+            d.layer = pod.layer;
+            d.x = pod.x;
+            d.y = pod.y;
+            d.z = pod.z;
+            d.width = pod.width;
+            d.height = pod.height;
+            d.depth = pod.depth;
+            if (auto* tex = static_cast<Texture*>(table.get(pod.handle)))
+            {
+                tex->upload(d);
+            }
+            return true;
+        }
+        case CommandType::wrapCanvasView:
+        {
+            // The consumer performs the real wrap reserved at record time.
+            auto pod = reader.read<WrapCanvasViewPOD>();
+            if (table.alive(pod.id, pod.generation))
+            {
+                return true;
+            }
+            if (pod.mode ==
+                static_cast<uint32_t>(WrapCanvasViewMode::imageView))
+            {
+                // A null image, still decoding or churned, leaves the slot
+                // empty so downstream draws drop instead of binding a null.
+                rive::RenderImage* image =
+                    imageAt ? imageAt(pod.canvasId) : nullptr;
+                auto* riveImage =
+                    image ? lite_rtti_cast<rive::RiveRenderImage*>(image)
+                          : nullptr;
+                rive::gpu::Texture* tex =
+                    riveImage ? riveImage->getTexture() : nullptr;
+                rcp<TextureView> wrapped;
+                if (tex != nullptr)
+                {
+                    wrapped = ctx.wrapRiveTexture(tex,
+                                                  image->width(),
+                                                  image->height());
+                }
+                else
+                {
+                    skipUnresolvedMake(pod.id, pod.generation, "wrapImageView");
+                }
+                table.set(pod.id,
+                          std::move(wrapped),
+                          pod.generation,
+                          OreKind::textureView);
+                return true;
+            }
+            rive::gpu::RenderCanvas* canvas =
+                canvasAt ? canvasAt(pod.canvasId) : nullptr;
+            assert(canvas != nullptr);
+            rcp<TextureView> wrapped;
+            if (canvas != nullptr)
+            {
+                wrapped = pod.mode == static_cast<uint32_t>(
+                                          WrapCanvasViewMode::sampleView)
+                              ? ctx.wrapCanvasSampleView(canvas)
+                              : ctx.wrapCanvasTexture(canvas);
+            }
+            table.set(pod.id,
+                      std::move(wrapped),
+                      pod.generation,
+                      OreKind::textureView);
+            return true;
+        }
+        case CommandType::destroyResource:
+        {
+            auto pod = reader.read<DestroyResourcePOD>();
+            table.destroy(pod.handle, pod.generation);
+            return true;
+        }
+        default:
+            return false; // a pass command
+    }
+}
+
+// Consumes one command's payload without executing it, for tooling.
+inline void skipOreCommand(CommandType type, OreCommandReader& reader)
+{
+    reader.skip(orePayloadSizeOf(type));
+}
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_render_pass_recording.hpp b/renderer/include/rive/renderer/ore/cmd/ore_render_pass_recording.hpp
new file mode 100644
index 0000000..35ff3ef
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_render_pass_recording.hpp
@@ -0,0 +1,196 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_resource.hpp"
+#include "rive/renderer/ore/ore_context.hpp"
+#include "rive/renderer/ore/ore_render_pass.hpp"
+#include "utils/lite_rtti.hpp"
+
+// Deferred mode ore::RenderPass. Each virtual runs the base class validation
+// then appends the matching command to an OreCommandBuffer for later replay.
+// Validation stays on the recording thread and failing commands are not
+// appended, so replay only ever sees structurally valid streams.
+namespace rive::ore::cmd
+{
+
+class RenderPassRecording : public RenderPass
+{
+public:
+    // Mirrors a backend beginRenderPass so the validators see attachments.
+    RenderPassRecording(Context* context,
+                        OreCommandBuffer* cmd,
+                        const RenderPassDesc& desc) :
+        RenderPass(context), m_cmd(cmd)
+    {
+        populateAttachmentMetadata(desc);
+
+        BeginRenderPassCmd begin{};
+        begin.colorCount = desc.colorCount;
+        for (uint32_t i = 0; i < desc.colorCount && i < 4; ++i)
+        {
+            const ColorAttachment& src = desc.colorAttachments[i];
+            ColorAttachmentPOD& dst = begin.colors[i];
+            dst.view = idOf(src.view);
+            dst.resolveTarget = idOf(src.resolveTarget);
+            dst.loadOp = src.loadOp;
+            dst.storeOp = src.storeOp;
+            dst.clearR = src.clearColor.r;
+            dst.clearG = src.clearColor.g;
+            dst.clearB = src.clearColor.b;
+            dst.clearA = src.clearColor.a;
+        }
+        const DepthStencilAttachment& ds = desc.depthStencil;
+        begin.depthStencil.view = idOf(ds.view);
+        begin.depthStencil.depthLoadOp = ds.depthLoadOp;
+        begin.depthStencil.depthStoreOp = ds.depthStoreOp;
+        begin.depthStencil.depthClearValue = ds.depthClearValue;
+        begin.depthStencil.stencilLoadOp = ds.stencilLoadOp;
+        begin.depthStencil.stencilStoreOp = ds.stencilStoreOp;
+        begin.depthStencil.stencilClearValue = ds.stencilClearValue;
+        m_cmd->append(CommandType::beginRenderPass, begin);
+    }
+
+    void setPipeline(Pipeline* pipeline) override
+    {
+        if (!checkPipelineCompat(pipeline))
+        {
+            return;
+        }
+        m_cmd->append(CommandType::setPipeline, SetPipelineCmd{idOf(pipeline)});
+    }
+
+    void setVertexBuffer(uint32_t slot,
+                         Buffer* buffer,
+                         uint32_t offset = 0) override
+    {
+        m_cmd->append(CommandType::setVertexBuffer,
+                      SetVertexBufferCmd{slot, idOf(buffer), offset});
+    }
+
+    void setIndexBuffer(Buffer* buffer,
+                        IndexFormat format,
+                        uint32_t offset = 0) override
+    {
+        m_cmd->append(CommandType::setIndexBuffer,
+                      SetIndexBufferCmd{idOf(buffer), format, offset});
+    }
+
+    void setBindGroup(uint32_t groupIndex,
+                      BindGroup* bg,
+                      const uint32_t* dynamicOffsets = nullptr,
+                      uint32_t dynamicOffsetCount = 0) override
+    {
+        // Hold a strong reference so GC cannot free the group before replay.
+        if (groupIndex < kMaxBindGroups)
+        {
+            m_boundGroups[groupIndex] = ref_rcp(bg);
+        }
+
+        SetBindGroupCmd c{};
+        c.groupIndex = groupIndex;
+        c.bindGroup = idOf(bg);
+        c.dynamicOffsetCount = dynamicOffsetCount;
+        c.dynamicOffsetStart =
+            dynamicOffsetCount > 0
+                ? m_cmd->appendBlob(dynamicOffsets,
+                                    dynamicOffsetCount * sizeof(uint32_t))
+                : 0;
+        m_cmd->append(CommandType::setBindGroup, c);
+    }
+
+    void setViewport(float x,
+                     float y,
+                     float width,
+                     float height,
+                     float minDepth = 0.0f,
+                     float maxDepth = 1.0f) override
+    {
+        m_cmd->append(CommandType::setViewport,
+                      SetViewportCmd{x, y, width, height, minDepth, maxDepth});
+    }
+
+    void setScissorRect(uint32_t x,
+                        uint32_t y,
+                        uint32_t width,
+                        uint32_t height) override
+    {
+        m_cmd->append(CommandType::setScissorRect,
+                      SetScissorRectCmd{x, y, width, height});
+    }
+
+    void setStencilReference(uint32_t ref) override
+    {
+        m_cmd->append(CommandType::setStencilReference,
+                      SetStencilReferenceCmd{ref});
+    }
+
+    void setBlendColor(float r, float g, float b, float a) override
+    {
+        m_cmd->append(CommandType::setBlendColor, SetBlendColorCmd{r, g, b, a});
+    }
+
+    void draw(uint32_t vertexCount,
+              uint32_t instanceCount = 1,
+              uint32_t firstVertex = 0,
+              uint32_t firstInstance = 0) override
+    {
+        m_cmd->append(
+            CommandType::draw,
+            DrawCmd{vertexCount, instanceCount, firstVertex, firstInstance});
+    }
+
+    void drawIndexed(uint32_t indexCount,
+                     uint32_t instanceCount = 1,
+                     uint32_t firstIndex = 0,
+                     int32_t baseVertex = 0,
+                     uint32_t firstInstance = 0) override
+    {
+        m_cmd->append(CommandType::drawIndexed,
+                      DrawIndexedCmd{indexCount,
+                                     instanceCount,
+                                     firstIndex,
+                                     baseVertex,
+                                     firstInstance});
+    }
+
+    void finish() override
+    {
+        if (m_finished)
+        {
+            return;
+        }
+        m_cmd->appendOpcode(CommandType::finish);
+        m_finished = true;
+        for (uint32_t i = 0; i < kMaxBindGroups; ++i)
+        {
+            m_boundGroups[i] = nullptr;
+        }
+    }
+
+private:
+    // A deferred object self reports its creation id; a real resource falls
+    // back to the buffer's keep alive capture.
+    template <typename DeferredT, typename T> ResourceHandle idOfAs(T* r)
+    {
+        if (auto* d = lite_rtti_cast<DeferredT*>(r))
+        {
+            return d->clientHandle();
+        }
+        return m_cmd->capture(r);
+    }
+    ResourceHandle idOf(Buffer* b) { return idOfAs<DeferredBuffer>(b); }
+    ResourceHandle idOf(Pipeline* p) { return idOfAs<DeferredPipeline>(p); }
+    ResourceHandle idOf(TextureView* v)
+    {
+        return idOfAs<DeferredTextureView>(v);
+    }
+    ResourceHandle idOf(BindGroup* g) { return idOfAs<DeferredBindGroup>(g); }
+
+    OreCommandBuffer* m_cmd;
+};
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_replay.hpp b/renderer/include/rive/renderer/ore/cmd/ore_replay.hpp
new file mode 100644
index 0000000..694015e
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_replay.hpp
@@ -0,0 +1,340 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_make_replay.hpp"
+#include "rive/renderer/ore/ore_context.hpp"
+#include "rive/renderer/ore/ore_render_pass.hpp"
+#include <cassert>
+#include <functional>
+#include <memory>
+
+// Replays a recorded ore command stream against a live Context. Replay drives
+// the same virtuals as immediate mode, so one implementation covers every
+// backend and the pixels match immediate mode by construction.
+namespace rive::ore::cmd
+{
+
+// Maps a captured resource to the object replay should use. Identity when
+// passes captured real resources; deferred objects remap to the real object.
+using ResourceRemap =
+    std::function<rive::gpu::GPUResource*(rive::gpu::GPUResource*)>;
+
+// Resolves a render command handle to the object replay should use, so the
+// deferred objects recorded against can be discarded after recording.
+using HandleResolver = std::function<rive::gpu::GPUResource*(ResourceHandle)>;
+
+// Returns false for a lifecycle opcode. dropDraws poisons the open pass when
+// a handle fails to resolve so its draws drop instead of using garbage.
+inline bool replayPassCommand(Context& ctx,
+                              std::unique_ptr<RenderPass>& pass,
+                              bool& dropDraws,
+                              CommandType type,
+                              OreCommandReader& reader,
+                              const OreKindResolve& resolve)
+{
+    auto churned = [&](const char* what, ResourceHandle h) {
+        dropDraws = true;
+        RIVE_WARN_THROTTLED("rive ore replay: %s handle %u churned, dropping "
+                            "pass draws\n",
+                            what,
+                            h);
+    };
+    switch (type)
+    {
+        case CommandType::beginRenderPass:
+        {
+            auto c = reader.read<BeginRenderPassCmd>();
+            RenderPassDesc desc{};
+            desc.colorCount = c.colorCount;
+            for (uint32_t i = 0; i < c.colorCount && i < 4; ++i)
+            {
+                const ColorAttachmentPOD& src = c.colors[i];
+                ColorAttachment& dst = desc.colorAttachments[i];
+                dst.view = static_cast<TextureView*>(
+                    resolve(src.view, OreKind::textureView));
+                dst.resolveTarget = static_cast<TextureView*>(
+                    resolve(src.resolveTarget, OreKind::textureView));
+                dst.loadOp = src.loadOp;
+                dst.storeOp = src.storeOp;
+                dst.clearColor = {src.clearR,
+                                  src.clearG,
+                                  src.clearB,
+                                  src.clearA};
+            }
+            const DepthStencilAttachmentPOD& ds = c.depthStencil;
+            desc.depthStencil.view = static_cast<TextureView*>(
+                resolve(ds.view, OreKind::textureView));
+            desc.depthStencil.depthLoadOp = ds.depthLoadOp;
+            desc.depthStencil.depthStoreOp = ds.depthStoreOp;
+            desc.depthStencil.depthClearValue = ds.depthClearValue;
+            desc.depthStencil.stencilLoadOp = ds.stencilLoadOp;
+            desc.depthStencil.stencilStoreOp = ds.stencilStoreOp;
+            desc.depthStencil.stencilClearValue = ds.stencilClearValue;
+            dropDraws = false;
+            for (uint32_t i = 0; i < c.colorCount && i < 4; ++i)
+            {
+                if (desc.colorAttachments[i].view == nullptr &&
+                    c.colors[i].view != kInvalidHandle)
+                {
+                    churned("render pass view", c.colors[i].view);
+                    break;
+                }
+            }
+            if (dropDraws)
+            {
+                break; // pass stays null, its commands no-op
+            }
+            pass = ctx.beginRenderPass(desc);
+            break;
+        }
+        case CommandType::setPipeline:
+        {
+            auto c = reader.read<SetPipelineCmd>();
+            auto* pipeline =
+                static_cast<Pipeline*>(resolve(c.pipeline, OreKind::pipeline));
+            if (pipeline == nullptr && c.pipeline != kInvalidHandle)
+            {
+                churned("pipeline", c.pipeline);
+            }
+            else if (pass)
+            {
+                pass->setPipeline(pipeline);
+            }
+            break;
+        }
+        case CommandType::setVertexBuffer:
+        {
+            auto c = reader.read<SetVertexBufferCmd>();
+            auto* buffer =
+                static_cast<Buffer*>(resolve(c.buffer, OreKind::buffer));
+            if (buffer == nullptr && c.buffer != kInvalidHandle)
+            {
+                churned("vertex buffer", c.buffer);
+            }
+            else if (pass)
+            {
+                pass->setVertexBuffer(c.slot, buffer, c.offset);
+            }
+            break;
+        }
+        case CommandType::setIndexBuffer:
+        {
+            auto c = reader.read<SetIndexBufferCmd>();
+            auto* buffer =
+                static_cast<Buffer*>(resolve(c.buffer, OreKind::buffer));
+            if (buffer == nullptr && c.buffer != kInvalidHandle)
+            {
+                churned("index buffer", c.buffer);
+            }
+            else if (pass)
+            {
+                pass->setIndexBuffer(buffer, c.format, c.offset);
+            }
+            break;
+        }
+        case CommandType::setBindGroup:
+        {
+            auto c = reader.read<SetBindGroupCmd>();
+            const uint32_t* dynamicOffsets = nullptr;
+            if (c.dynamicOffsetCount > 0)
+            {
+                Span<const uint8_t> blob =
+                    reader.blobAt(c.dynamicOffsetStart,
+                                  c.dynamicOffsetCount * sizeof(uint32_t));
+                dynamicOffsets = reinterpret_cast<const uint32_t*>(blob.data());
+            }
+            auto* bindGroup = static_cast<BindGroup*>(
+                resolve(c.bindGroup, OreKind::bindGroup));
+            if (bindGroup == nullptr && c.bindGroup != kInvalidHandle)
+            {
+                churned("bind group", c.bindGroup);
+            }
+            else if (pass)
+            {
+                pass->setBindGroup(c.groupIndex,
+                                   bindGroup,
+                                   dynamicOffsets,
+                                   c.dynamicOffsetCount);
+            }
+            break;
+        }
+        case CommandType::setViewport:
+        {
+            auto c = reader.read<SetViewportCmd>();
+            if (pass)
+            {
+                pass->setViewport(c.x,
+                                  c.y,
+                                  c.width,
+                                  c.height,
+                                  c.minDepth,
+                                  c.maxDepth);
+            }
+            break;
+        }
+        case CommandType::setScissorRect:
+        {
+            auto c = reader.read<SetScissorRectCmd>();
+            if (pass)
+            {
+                pass->setScissorRect(c.x, c.y, c.width, c.height);
+            }
+            break;
+        }
+        case CommandType::setStencilReference:
+        {
+            auto c = reader.read<SetStencilReferenceCmd>();
+            if (pass)
+            {
+                pass->setStencilReference(c.ref);
+            }
+            break;
+        }
+        case CommandType::setBlendColor:
+        {
+            auto c = reader.read<SetBlendColorCmd>();
+            if (pass)
+            {
+                pass->setBlendColor(c.r, c.g, c.b, c.a);
+            }
+            break;
+        }
+        case CommandType::draw:
+        {
+            auto c = reader.read<DrawCmd>();
+            if (pass && !dropDraws)
+            {
+                pass->draw(c.vertexCount,
+                           c.instanceCount,
+                           c.firstVertex,
+                           c.firstInstance);
+            }
+            break;
+        }
+        case CommandType::drawIndexed:
+        {
+            auto c = reader.read<DrawIndexedCmd>();
+            if (pass && !dropDraws)
+            {
+                pass->drawIndexed(c.indexCount,
+                                  c.instanceCount,
+                                  c.firstIndex,
+                                  c.baseVertex,
+                                  c.firstInstance);
+            }
+            break;
+        }
+        case CommandType::finish:
+        {
+            if (pass)
+            {
+                pass->finish();
+                pass.reset();
+            }
+            break;
+        }
+        default:
+            return false; // lifecycle opcode, handled elsewhere
+    }
+    return true;
+}
+
+// Replays a passes only stream, resolving every handle via resolveHandle.
+inline void replayCommandBufferResolved(Context& ctx,
+                                        const OreCommandBuffer& cmd,
+                                        const HandleResolver& resolveHandle)
+{
+    // Handles here index the buffer's own typed keep alive table, so the
+    // kind is already guaranteed and only the null check applies.
+    auto resolve = [&](ResourceHandle h, OreKind) -> rive::gpu::GPUResource* {
+        return h == kInvalidHandle ? nullptr : resolveHandle(h);
+    };
+    OreCommandReader reader(cmd.commandBytes(), cmd.blobBytes());
+    std::unique_ptr<RenderPass> pass;
+    bool dropDraws = false;
+    CommandType type;
+    while (reader.next(type))
+    {
+        if (!replayPassCommand(ctx, pass, dropDraws, type, reader, resolve))
+        {
+            // The payload was not consumed, so later reads would desync.
+            assert(false);
+            break;
+        }
+    }
+}
+
+// Replays the single ordered stream. Flagged ids resolve via real; id reuse
+// is safe because destroys are consumed in stream order.
+inline void replayOreStream(Context& ctx,
+                            Span<const uint8_t> commands,
+                            Span<const uint8_t> blobs,
+                            OreResident& table,
+                            const OreHandleResolve& real = nullptr,
+                            const OreCanvasResolve& canvasAt = {},
+                            const OreImageResolve& imageAt = {})
+{
+    auto resolve = [&](ResourceHandle h,
+                       OreKind kind) -> rive::gpu::GPUResource* {
+        return resolveOre(table, real, h, kind);
+    };
+    OreCommandReader reader(commands, blobs);
+    std::unique_ptr<RenderPass> pass;
+    bool dropDraws = false;
+    CommandType type;
+    while (reader.next(type))
+    {
+        if (!replayOreLifecycle(ctx,
+                                table,
+                                type,
+                                reader,
+                                resolve,
+                                canvasAt,
+                                imageAt) &&
+            !replayPassCommand(ctx, pass, dropDraws, type, reader, resolve))
+        {
+            // The payload was not consumed, so later reads would desync.
+            assert(false);
+            break;
+        }
+    }
+}
+
+inline void replayOreStream(Context& ctx,
+                            const OreCommandBuffer& cmd,
+                            OreResident& table,
+                            const OreHandleResolve& real = nullptr,
+                            const OreCanvasResolve& canvasAt = {},
+                            const OreImageResolve& imageAt = {})
+{
+    replayOreStream(ctx,
+                    cmd.commandBytes(),
+                    cmd.blobBytes(),
+                    table,
+                    real,
+                    canvasAt,
+                    imageAt);
+}
+
+// Standalone buffer: handles index the buffer's own keep alive table, then
+// run through an optional remap.
+inline void replayCommandBuffer(Context& ctx,
+                                const OreCommandBuffer& cmd,
+                                const ResourceRemap& remap = nullptr)
+{
+    const std::vector<rcp<rive::gpu::GPUResource>>& keep = cmd.keepAlive();
+    replayCommandBufferResolved(
+        ctx,
+        cmd,
+        [&keep, &remap](ResourceHandle h) -> rive::gpu::GPUResource* {
+            rive::gpu::GPUResource* r =
+                h < keep.size() ? keep[h].get() : nullptr;
+            return (remap && r) ? remap(r) : r;
+        });
+}
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_resource_commands.hpp b/renderer/include/rive/renderer/ore/cmd/ore_resource_commands.hpp
new file mode 100644
index 0000000..5bfb051
--- /dev/null
+++ b/renderer/include/rive/renderer/ore/cmd/ore_resource_commands.hpp
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/ore/ore_types.hpp"
+#include "rive/renderer/ore/cmd/ore_handle.hpp"
+#include <cstdint>
+
+// Recorded form of the ore Context make* calls. References between resources
+// are recorded as client handles, so recording order is a valid creation order
+// by construction. Variable length data lives in a companion blob arena.
+namespace rive::ore::cmd
+{
+
+// size == kAbsent means the source field was null, distinct from an empty but
+// present payload. Offsets are 64 bit so never-reset streams outlive 4 GiB of
+// cumulative appends.
+struct BlobRef
+{
+    uint64_t offset;
+    uint32_t size;
+    uint32_t pad; // explicit so the wire layout carries no implicit padding
+    static constexpr uint32_t kAbsent = ~0u;
+    bool absent() const { return size == kAbsent; }
+};
+constexpr BlobRef kNoBlob = {0, BlobRef::kAbsent, 0};
+
+struct BufferDescPOD
+{
+    BufferUsage usage;
+    uint32_t size;
+    bool immutable;
+    BlobRef data;  // initial contents, or absent
+    BlobRef label; // null-terminated, or absent
+};
+
+struct TextureDescPOD
+{
+    uint32_t width;
+    uint32_t height;
+    uint32_t depthOrArrayLayers;
+    TextureFormat format;
+    TextureType type;
+    bool renderTarget;
+    uint32_t numMipmaps;
+    uint32_t sampleCount;
+    BlobRef label;
+};
+
+struct SamplerDescPOD
+{
+    Filter minFilter;
+    Filter magFilter;
+    Filter mipmapFilter;
+    WrapMode wrapU;
+    WrapMode wrapV;
+    WrapMode wrapW;
+    CompareFunction compare;
+    float minLod;
+    float maxLod;
+    uint32_t maxAnisotropy;
+    BlobRef label;
+};
+
+// The size fields are recovered from each blob's size at replay.
+struct ShaderModuleDescPOD
+{
+    BlobRef code;
+    ShaderLanguage language;
+    ShaderStage stage;
+    BlobRef label;
+    BlobRef hlslSource;     // D3D11 runtime-compile source, or absent
+    BlobRef hlslEntryPoint; // null-terminated, or absent
+    BlobRef bindingMapBytes;
+    BlobRef glFixupBytes;
+    uint32_t shaderAssetId;
+};
+
+struct BindGroupLayoutDescPOD
+{
+    uint32_t groupIndex;
+    BlobRef entries; // entryCount * sizeof(BindGroupLayoutEntry), or absent
+    uint32_t entryCount;
+    BlobRef label;
+};
+
+struct TextureViewDescPOD
+{
+    ResourceHandle texture;
+    TextureViewDimension dimension;
+    TextureAspect aspect;
+    uint32_t baseMipLevel;
+    uint32_t mipCount;
+    uint32_t baseLayer;
+    uint32_t layerCount;
+};
+
+struct VertexBufferLayoutPOD
+{
+    uint32_t stride;
+    VertexStepMode stepMode;
+    uint32_t attributeCount;
+    BlobRef attributes; // attributeCount * sizeof(VertexAttribute)
+};
+
+// Module and layout references are client handles.
+struct PipelineDescPOD
+{
+    ResourceHandle vertexModule;
+    BlobRef vertexEntryPoint; // null-terminated string
+    ResourceHandle fragmentModule;
+    BlobRef fragmentEntryPoint;
+
+    BlobRef vertexBuffers; // vertexBufferCount * sizeof(VertexBufferLayoutPOD)
+    uint32_t vertexBufferCount;
+
+    PrimitiveTopology topology;
+    IndexFormat indexFormat;
+    CullMode cullMode;
+    FaceWinding winding;
+
+    ColorTargetState colorTargets[4];
+    uint32_t colorCount;
+
+    DepthStencilState depthStencil;
+    StencilFaceState stencilFront;
+    StencilFaceState stencilBack;
+    uint8_t stencilReadMask;
+    uint8_t stencilWriteMask;
+
+    uint32_t sampleCount;
+
+    BlobRef bindGroupLayouts; // bindGroupLayoutCount * sizeof(ResourceHandle)
+    uint32_t bindGroupLayoutCount;
+
+    BlobRef label;
+};
+
+// BindGroupDesc entries with resource pointers replaced by client handles.
+struct UBOEntryPOD
+{
+    uint32_t slot;
+    ResourceHandle buffer;
+    uint32_t offset;
+    uint32_t size;
+};
+struct TexEntryPOD
+{
+    uint32_t slot;
+    ResourceHandle view;
+};
+struct SampEntryPOD
+{
+    uint32_t slot;
+    ResourceHandle sampler;
+};
+
+struct BindGroupDescPOD
+{
+    ResourceHandle layout;
+    BlobRef ubos; // uboCount * sizeof(UBOEntryPOD)
+    uint32_t uboCount;
+    BlobRef textures; // textureCount * sizeof(TexEntryPOD)
+    uint32_t textureCount;
+    BlobRef samplers; // samplerCount * sizeof(SampEntryPOD)
+    uint32_t samplerCount;
+    BlobRef label;
+};
+
+} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/ore_context.hpp b/renderer/include/rive/renderer/ore/ore_context.hpp
index b3eed5c..5b8fd72 100644
--- a/renderer/include/rive/renderer/ore/ore_context.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context.hpp
@@ -6,10 +6,13 @@
 
 #include <cstdarg>
 #include <cstdio>
+#include <cstdlib>
 #include <memory>
 #include <string>
 #include <vector>
 #include "rive/refcnt.hpp"
+#include "rive/renderer/render_canvas.hpp"
+#include "rive/renderer/rive_render_image.hpp"
 #include "rive/renderer/ore/ore_types.hpp"
 #include "rive/renderer/ore/ore_buffer.hpp"
 #include "rive/renderer/ore/ore_texture.hpp"
@@ -18,6 +21,12 @@
 #include "rive/renderer/ore/ore_pipeline.hpp"
 #include "rive/renderer/ore/ore_bind_group.hpp"
 #include "rive/renderer/ore/ore_render_pass.hpp"
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+
+namespace rive
+{
+class RenderImage;
+}
 
 namespace rive::gpu
 {
@@ -107,6 +116,52 @@
     virtual void waitForGPU() = 0;
 
     virtual rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) = 0;
+
+    // Color format makeRenderCanvas allocates. A backend that allocates
+    // anything other than rgba8 must override or canvas draws fail the
+    // pipeline compat check at replay.
+    //
+    // An override also has to reach the recorder before any pass records
+    // against a canvas, which a host that binds its real context late cannot
+    // do. DeferredOreContext tripwires on a late bind whose override
+    // disagrees with the default it already recorded.
+    virtual TextureFormat canvasTargetFormat() const
+    {
+        return TextureFormat::rgba8unorm;
+    }
+
+    // True only for the deferred recording context. Callers that would touch
+    // the driver immediately take a recording path instead.
+    virtual bool isRecording() const { return false; }
+
+    // Recording form of Image:view on a canvas backed image. Only the
+    // deferred context implements this, gated by isRecording.
+    virtual rcp<TextureView> recordWrapCanvasImage(RenderImage* /*image*/,
+                                                   uint32_t /*width*/,
+                                                   uint32_t /*height*/)
+    {
+        return nullptr;
+    }
+
+    // Recording form of Image:view on a decoded image. Only the deferred
+    // context implements this.
+    virtual rcp<TextureView> recordWrapImageView(uint32_t /*imageId*/,
+                                                 uint32_t /*width*/,
+                                                 uint32_t /*height*/)
+    {
+        return nullptr;
+    }
+
+    // Sampling wrap of a 2D canvas for Image:view. GL overrides to insert its
+    // Y flip mirror.
+    virtual rcp<TextureView> wrapCanvasSampleView(gpu::RenderCanvas* canvas)
+    {
+        auto* image = canvas->renderImage();
+        return wrapRiveTexture(image->getTexture(),
+                               canvas->width(),
+                               canvas->height());
+    }
+
     virtual rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex,
                                              uint32_t width,
                                              uint32_t height) = 0;
@@ -114,11 +169,20 @@
     // Which RSTB shader variant this backend consumes.
     virtual ShaderTarget shaderTarget() const = 0;
 
+    // Whether features() describes a device that will actually run the work.
+    // Only a recording context with no replay device bound yet answers false:
+    // its m_features still holds Features' own initializers, which read as a
+    // real low end device and are indistinguishable from one. A caller that
+    // would branch on a capability must ask this first, because a recorded
+    // branch replays on the device it guessed wrong about.
+    virtual bool featuresKnown() const { return true; }
+
     // ------------------------------------------------------------------------
     // Cross-cutting state and accessors. Non-virtual; live on this base
     // because they are uniform across backends.
     // ------------------------------------------------------------------------
 
+    // Only meaningful when featuresKnown().
     const Features& features() const { return m_features; }
 
     // Active render pass tracking — used by Lua bindings to auto-finish
@@ -126,6 +190,19 @@
     RenderPass* activeRenderPass() const { return m_activeRenderPass; }
     void setActiveRenderPass(RenderPass* pass) { m_activeRenderPass = pass; }
 
+    // When on, the render pass entry point records and replays instead of
+    // issuing immediately. Seeded from the RIVE_ORE_DEFER env var.
+    bool deferredRecording() const { return m_deferredRecording; }
+    void setDeferredRecording(bool deferred) { m_deferredRecording = deferred; }
+
+    // True when the backend replays the accumulated pendingFrame at endFrame.
+    // False falls back to per pass inline replay, which is byte identical.
+    virtual bool usesDeferredFrameReplay() const { return false; }
+
+    // Per frame stream deferred passes record into; the backend drains it at
+    // endFrame.
+    cmd::OreCommandBuffer& pendingFrame() { return m_pendingFrame; }
+
     // Called at the top of every backend's beginRenderPass(). If a prior pass
     // is still open, finish it — matches the Lua binding's auto-finish
     // contract and means backends that enforce one-encoder-at-a-time (Metal,
@@ -171,7 +248,11 @@
 protected:
     Context(rcp<rive::gpu::GPUResourceManager> manager) :
         m_manager(std::move(manager))
-    {}
+    {
+#ifndef NO_GETENV
+        m_deferredRecording = getenv("RIVE_ORE_DEFER") != nullptr;
+#endif
+    }
 
     Features m_features;
 
@@ -183,6 +264,10 @@
     // Last validation error from setPipeline() / setBindGroup().
     std::string m_lastError;
 
+    bool m_deferredRecording = false;
+
+    cmd::OreCommandBuffer m_pendingFrame;
+
     // Back-pointer to the GPUResourceManager for GPUResource lifecycle
     // this is actually owned by the render context impl that created the given
     // ore context but its held here for convenience of ore resources that need
diff --git a/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp b/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp
index b4103dd..4419f40 100644
--- a/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp
@@ -94,6 +94,21 @@
                                           uint32_t w,
                                           uint32_t h);
 
+    // Recording a copy directly would land on the host command list even while
+    // it is closed between frames, so uploads stage here until one is live.
+    struct D3D12PendingTextureUpload
+    {
+        rcp<Texture> texture;
+        rcp<Buffer> staging;
+        D3D12_PLACED_SUBRESOURCE_FOOTPRINT footprint;
+        UINT subresource;
+        UINT dstX;
+        UINT dstY;
+        UINT dstZ;
+    };
+    void d3d12QueuePendingTextureUpload(D3D12PendingTextureUpload pending);
+    void d3d12FlushPendingTextureUploads();
+
     Microsoft::WRL::ComPtr<ID3D12Device> m_d3dDevice;
     // Microsoft::WRL::ComPtr<ID3D12CommandQueue> m_d3dQueue;
     //  Active command list for the current frame. Points at m_d3dOwnedCmdList
@@ -101,6 +116,8 @@
     //  mode. All recording code reads through this pointer, so the two modes
     //  share one code path.
     ID3D12GraphicsCommandList* m_d3dCmdList = nullptr;
+    // Drained at the next beginFrame or beginRenderPass.
+    std::vector<D3D12PendingTextureUpload> m_d3dPendingUploads;
     // resource-creation time.
     Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> m_d3dCpuSrvHeap;
     Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> m_d3dCpuRtvHeap;
diff --git a/renderer/include/rive/renderer/ore/ore_context_gl.hpp b/renderer/include/rive/renderer/ore/ore_context_gl.hpp
index 905c72b..06a68d3 100644
--- a/renderer/include/rive/renderer/ore/ore_context_gl.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context_gl.hpp
@@ -6,6 +6,8 @@
 
 #include "rive/renderer/ore/ore_context.hpp"
 
+#include <unordered_map>
+
 // Note: load_gles_extensions.hpp (glad) is intentionally NOT included here.
 // The private GL state only needs 'int' (GLint is always int), keeping this
 // header free of glad so it can be included without glad in the search path.
@@ -20,7 +22,9 @@
 class ContextGL : public Context
 {
 public:
-    static std::unique_ptr<ContextGL> Make();
+    // renderContextImpl is the RenderContextGLImpl that owns this context's
+    // canvases, needed for the Y flip import mirror. Null on standalone GMs.
+    static std::unique_ptr<ContextGL> Make(void* renderContextImpl = nullptr);
 
     ~ContextGL() override;
 
@@ -43,7 +47,13 @@
     void endFrame() override;
     void waitForGPU() override;
 
+    // GL stays on per pass inline replay: it has no command buffer so no
+    // natural frame boundary drain, and the ore frame is not reliably driven.
+    // TODO: whole frame GL deferral.
+    bool usesDeferredFrameReplay() const override { return false; }
+
     rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override;
+    rcp<TextureView> wrapCanvasSampleView(gpu::RenderCanvas* canvas) override;
     rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex,
                                      uint32_t width,
                                      uint32_t height) override;
@@ -58,7 +68,12 @@
     friend class BindGroupGL;
     friend class TextureGL;
 
-    ContextGL() : Context(nullptr) {}
+    explicit ContextGL(void* renderContextImpl) :
+        Context(nullptr), m_renderContextImpl(renderContextImpl)
+    {}
+
+    // Borrowed RenderContextGLImpl, void* to avoid the header dependency.
+    void* m_renderContextImpl = nullptr;
 
     // GL state tracking for save/restore at frame boundaries.
     // NOTE: GL_ELEMENT_ARRAY_BUFFER is intentionally excluded — it is VAO
diff --git a/renderer/include/rive/renderer/ore/ore_context_metal.hpp b/renderer/include/rive/renderer/ore/ore_context_metal.hpp
index e23a199..6e169ab 100644
--- a/renderer/include/rive/renderer/ore/ore_context_metal.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context_metal.hpp
@@ -45,6 +45,9 @@
     void endFrame() override;
     void waitForGPU() override;
 
+    // Metal drains the recorded frame in endFrame before commit.
+    bool usesDeferredFrameReplay() const override { return true; }
+
     rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override;
     rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex,
                                      uint32_t width,
diff --git a/renderer/include/rive/renderer/ore/ore_pipeline.hpp b/renderer/include/rive/renderer/ore/ore_pipeline.hpp
index 80eec86..e6b8633 100644
--- a/renderer/include/rive/renderer/ore/ore_pipeline.hpp
+++ b/renderer/include/rive/renderer/ore/ore_pipeline.hpp
@@ -68,6 +68,7 @@
         {
             m_layouts[i] = ref_rcp(desc.bindGroupLayouts[i]);
         }
+        ownVertexLayout();
     }
 
     Pipeline(rcp<rive::gpu::GPUResourceManager> manager,
@@ -96,9 +97,51 @@
         {
             m_layouts[i] = ref_rcp(desc.bindGroupLayouts[i]);
         }
+        ownVertexLayout();
     }
 
     PipelineDesc m_desc;
+
+private:
+    // The desc's vertex layout points into caller memory the deferred replay
+    // frees right after makePipeline, so deep copy it into owned storage.
+    std::vector<VertexBufferLayout> m_ownedVertexBuffers;
+    std::vector<VertexAttribute> m_ownedAttributes;
+
+    void ownVertexLayout()
+    {
+        if (m_desc.vertexBufferCount == 0 || m_desc.vertexBuffers == nullptr)
+        {
+            m_desc.vertexBuffers = nullptr;
+            m_desc.vertexBufferCount = 0;
+            return;
+        }
+        // Reserve up front so the vector never reallocates while we repoint
+        // into it.
+        size_t total = 0;
+        for (uint32_t i = 0; i < m_desc.vertexBufferCount; ++i)
+        {
+            total += m_desc.vertexBuffers[i].attributeCount;
+        }
+        m_ownedAttributes.reserve(total);
+        m_ownedVertexBuffers.assign(m_desc.vertexBuffers,
+                                    m_desc.vertexBuffers +
+                                        m_desc.vertexBufferCount);
+        for (uint32_t i = 0; i < m_desc.vertexBufferCount; ++i)
+        {
+            const VertexBufferLayout& src = m_desc.vertexBuffers[i];
+            size_t start = m_ownedAttributes.size();
+            if (src.attributes != nullptr && src.attributeCount > 0)
+            {
+                m_ownedAttributes.insert(m_ownedAttributes.end(),
+                                         src.attributes,
+                                         src.attributes + src.attributeCount);
+            }
+            m_ownedVertexBuffers[i].attributes =
+                src.attributeCount > 0 ? &m_ownedAttributes[start] : nullptr;
+        }
+        m_desc.vertexBuffers = m_ownedVertexBuffers.data();
+    }
 };
 
 } // namespace rive::ore
diff --git a/renderer/include/rive/renderer/render_context.hpp b/renderer/include/rive/renderer/render_context.hpp
index de79b0b..7f4dbe7 100644
--- a/renderer/include/rive/renderer/render_context.hpp
+++ b/renderer/include/rive/renderer/render_context.hpp
@@ -305,10 +305,18 @@
     // Creates a RenderCanvas: a GPU texture usable as both a render target
     // (for rendering into) and a render image (for compositing into draws).
     rcp<RenderCanvas> makeRenderCanvas(uint32_t width, uint32_t height);
+
+    // Like makeRenderCanvas, but on GL the deferred replay worker lazily
+    // allocates the texture on its own context instead of this one.
+    rcp<RenderCanvas> makeDeferredRenderCanvas(uint32_t width, uint32_t height);
+
     rive::ore::Context* ore() override;
     rive::ore::Context* getOreContext() { return ore(); }
 #endif
 
+    // Importing straight through a render context routes scripts to it.
+    Factory* renderContext() override { return this; }
+
 private:
     friend class Draw;
     friend class PathDraw;
diff --git a/renderer/include/rive/renderer/render_context_impl.hpp b/renderer/include/rive/renderer/render_context_impl.hpp
index b89946c..803d87f 100644
--- a/renderer/include/rive/renderer/render_context_impl.hpp
+++ b/renderer/include/rive/renderer/render_context_impl.hpp
@@ -81,6 +81,15 @@
         return nullptr;
     }
 
+    // Deferred allocation is only distinct on GL, where the replay worker
+    // must own the texture on its own context. Everywhere else the device
+    // is shared and eager allocation is correct.
+    virtual rcp<RenderCanvas> makeDeferredRenderCanvas(uint32_t width,
+                                                       uint32_t height)
+    {
+        return makeRenderCanvas(width, height);
+    }
+
     // If canvas is enabled then the backend Impl MUST implement this.
     virtual std::unique_ptr<rive::ore::Context> makeOreContext() = 0;
 #endif
diff --git a/renderer/path_fiddle/fiddle_context_d3d12.cpp b/renderer/path_fiddle/fiddle_context_d3d12.cpp
index 2a426a0..5948a7a 100644
--- a/renderer/path_fiddle/fiddle_context_d3d12.cpp
+++ b/renderer/path_fiddle/fiddle_context_d3d12.cpp
@@ -25,6 +25,27 @@
 using namespace rive;
 using namespace rive::gpu;
 
+// Set once a callback prints each debug-layer message as it posts, so a
+// mid-frame break surfaces its reason before the process dies.
+static bool s_d3d12MessageCallbackActive = false;
+
+#ifdef DEBUG
+static void __stdcall D3D12MessageCallback(D3D12_MESSAGE_CATEGORY category,
+                                           D3D12_MESSAGE_SEVERITY severity,
+                                           D3D12_MESSAGE_ID id,
+                                           LPCSTR description,
+                                           void*)
+{
+    fprintf(stderr,
+            "[D3D12 debug @ live] sev=%d id=%d cat=%d: %s\n",
+            static_cast<int>(severity),
+            static_cast<int>(id),
+            static_cast<int>(category),
+            description);
+    fflush(stderr);
+}
+#endif
+
 // Drain the D3D12 debug-layer info queue and print any stored messages to
 // stderr. Modeled on Dawn's AppendDebugLayerMessagesToError
 // (dawn/src/dawn/native/d3d12/DeviceD3D12.cpp). VERIFY_OK aborts on the bare
@@ -41,6 +62,12 @@
     {
         return;
     }
+    // The live callback already printed each message as it posted.
+    if (s_d3d12MessageCallbackActive)
+    {
+        infoQueue->ClearStoredMessages();
+        return;
+    }
     UINT64 numMessages = infoQueue->GetNumStoredMessages();
     for (UINT64 i = 0; i < numMessages; ++i)
     {
@@ -780,6 +807,26 @@
         return nullptr;
     }
 
+#ifdef DEBUG
+    // Print debug-layer messages as they post; the frame-boundary drain misses
+    // a mid-frame break that kills the process first.
+    {
+        ComPtr<ID3D12InfoQueue1> infoQueue1;
+        if (SUCCEEDED(device->QueryInterface(IID_PPV_ARGS(&infoQueue1))))
+        {
+            DWORD cookie = 0;
+            if (SUCCEEDED(infoQueue1->RegisterMessageCallback(
+                    &D3D12MessageCallback,
+                    D3D12_MESSAGE_CALLBACK_FLAG_NONE,
+                    nullptr,
+                    &cookie)))
+            {
+                s_d3d12MessageCallbackActive = true;
+            }
+        }
+    }
+#endif
+
     if (fiddleOptions.disableRasterOrdering)
     {
         contextOptions.disableRasterizerOrderedViews = true;
diff --git a/renderer/src/deferred_cmd.cpp b/renderer/src/deferred_cmd.cpp
new file mode 100644
index 0000000..53bc2d6
--- /dev/null
+++ b/renderer/src/deferred_cmd.cpp
@@ -0,0 +1,675 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#include "rive/renderer/cmd/deferred_render_factory.hpp"
+#include "rive/renderer/cmd/render_replay.hpp"
+
+// Out of line home for the large deferred stream bodies, so every TU that
+// touches the deferred headers does not recompile them.
+namespace rive::cmd
+{
+
+bool sniffImageSize(Span<const uint8_t> b, int& w, int& h)
+{
+    const uint8_t* d = b.data();
+    size_t n = b.size();
+    auto be32 = [&](size_t i) {
+        return (d[i] << 24) | (d[i + 1] << 16) | (d[i + 2] << 8) | d[i + 3];
+    };
+    // PNG: 8-byte sig, then IHDR with width/height as big-endian u32 at 16/20.
+    if (n >= 24 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G')
+    {
+        w = static_cast<int>(be32(16));
+        h = static_cast<int>(be32(20));
+        return true;
+    }
+    // GIF: "GIF87a"/"GIF89a", then width/height little-endian u16 at 6/8.
+    if (n >= 10 && d[0] == 'G' && d[1] == 'I' && d[2] == 'F')
+    {
+        w = d[6] | (d[7] << 8);
+        h = d[8] | (d[9] << 8);
+        return true;
+    }
+    // WEBP: RIFF....WEBP; VP8 / VP8L / VP8X carry dims at known offsets.
+    if (n >= 30 && d[0] == 'R' && d[1] == 'I' && d[2] == 'F' && d[3] == 'F' &&
+        d[8] == 'W' && d[9] == 'E' && d[10] == 'B' && d[11] == 'P')
+    {
+        if (d[12] == 'V' && d[13] == 'P' && d[14] == '8' && d[15] == ' ')
+        {
+            w = (d[26] | (d[27] << 8)) & 0x3fff;
+            h = (d[28] | (d[29] << 8)) & 0x3fff;
+            return true;
+        }
+        if (d[12] == 'V' && d[13] == 'P' && d[14] == '8' && d[15] == 'L')
+        {
+            uint32_t bits =
+                d[21] | (d[22] << 8) | (d[23] << 16) | (d[24] << 24);
+            w = static_cast<int>((bits & 0x3fff) + 1);
+            h = static_cast<int>(((bits >> 14) & 0x3fff) + 1);
+            return true;
+        }
+        if (d[12] == 'V' && d[13] == 'P' && d[14] == '8' && d[15] == 'X')
+        {
+            w = (d[24] | (d[25] << 8) | (d[26] << 16)) + 1;
+            h = (d[27] | (d[28] << 8) | (d[29] << 16)) + 1;
+            return true;
+        }
+    }
+    // JPEG: walk markers to an SOF (0xC0..0xCF, excluding non-SOF) for dims.
+    if (n >= 4 && d[0] == 0xff && d[1] == 0xd8)
+    {
+        size_t i = 2;
+        while (i + 9 < n)
+        {
+            if (d[i] != 0xff)
+            {
+                i++;
+                continue;
+            }
+            uint8_t marker = d[i + 1];
+            // SOF0..SOF15 carry dims; skip DHT(C4)/DAA(C8)/DAC(CC) which don't.
+            if (marker >= 0xc0 && marker <= 0xcf && marker != 0xc4 &&
+                marker != 0xc8 && marker != 0xcc)
+            {
+                h = (d[i + 5] << 8) | d[i + 6];
+                w = (d[i + 7] << 8) | d[i + 8];
+                return true;
+            }
+            uint32_t seg = (d[i + 2] << 8) | d[i + 3];
+            i += 2 + seg;
+        }
+    }
+    return false;
+}
+
+void replayRenderCommands(Factory* factory,
+                          Renderer* renderer,
+                          Span<const uint8_t> commands,
+                          Span<const uint8_t> blobs,
+                          ResourceTable& table,
+                          const ReplayHooks& hooks)
+{
+    auto& paths = table.paths;
+    auto& paints = table.paints;
+    auto& shaders = table.shaders;
+    auto& images = table.images;
+    auto& buffers = table.buffers;
+
+    auto filterAllows = [](ReplayFilter f, RenderCmd c) {
+        if (f == ReplayFilter::all)
+        {
+            return true;
+        }
+        switch (c)
+        {
+            case RenderCmd::save:
+            case RenderCmd::restore:
+            case RenderCmd::transform:
+            case RenderCmd::drawPath:
+            case RenderCmd::clipPath:
+            case RenderCmd::drawImage:
+            case RenderCmd::drawImageMesh:
+            case RenderCmd::modulateOpacity:
+            case RenderCmd::canvasContentBegin:
+            case RenderCmd::canvasContentEnd:
+                return f == ReplayFilter::draws;
+            case RenderCmd::destroyResource:
+                return f == ReplayFilter::destroys;
+            default:
+                return f == ReplayFilter::resources;
+        }
+    };
+
+    RenderCommandReader reader(commands, blobs);
+    auto path = [&](RenderHandle h) -> RenderPath* { return paths.get(h); };
+    auto paint = [&](RenderHandle h) -> RenderPaint* { return paints.get(h); };
+    auto image = [&](RenderHandle h) -> RenderImage* { return images.get(h); };
+    auto sampler = [](uint8_t wx, uint8_t wy, uint8_t f) {
+        return ImageSampler{static_cast<ImageWrap>(wx),
+                            static_cast<ImageWrap>(wy),
+                            static_cast<ImageFilter>(f)};
+    };
+
+    // Draws route into cur: the screen by default, or the active canvas
+    // between content brackets. Null drops the draw.
+    Renderer* cur = renderer;
+
+    uint8_t type;
+    uint8_t prevType = 255;
+    size_t prevPos = 0;
+    while (reader.next(type))
+    {
+        if (type > static_cast<uint8_t>(RenderCmd::lastRenderCmd))
+        {
+            // Unknown opcode: its payload was not consumed, so every later
+            // read would desync. Stop.
+            fprintf(stderr,
+                    "rive replay ABORT: opcode %u at byte %zu of %zu, last "
+                    "good opcode %u at byte %zu\n",
+                    type,
+                    reader.position() - 1,
+                    commands.size(),
+                    prevType,
+                    prevPos);
+            assert(false);
+            break;
+        }
+        prevType = type;
+        prevPos = reader.position() - 1;
+        const auto cmd = static_cast<RenderCmd>(type);
+        if (!filterAllows(hooks.filter, cmd))
+        {
+            reader.skip(payloadSizeOf(cmd));
+            continue;
+        }
+        switch (cmd)
+        {
+            case RenderCmd::makePath:
+            {
+                auto c = reader.read<MakePathPOD>();
+                RawPath raw = rebuildRawPath(
+                    reader.blobAt(c.blobOffset,
+                                  c.verbCount *
+                                      static_cast<uint32_t>(sizeof(PathVerb))),
+                    reader.blobAt(c.pointsOffset,
+                                  c.pointCount *
+                                      static_cast<uint32_t>(sizeof(Vec2D))));
+                paths.set(
+                    c.id,
+                    factory->makeRenderPath(raw,
+                                            static_cast<FillRule>(c.fillRule)),
+                    c.generation);
+                if (c.id >= table.pathFillRules.size())
+                {
+                    table.pathFillRules.resize(c.id + 1);
+                }
+                table.pathFillRules[c.id] = c.fillRule;
+                break;
+            }
+            case RenderCmd::makeEmptyPath:
+            {
+                auto c = reader.read<MakeIdPOD>();
+                paths.set(c.id, factory->makeEmptyRenderPath(), c.generation);
+                if (c.id >= table.pathFillRules.size())
+                {
+                    table.pathFillRules.resize(c.id + 1);
+                }
+                table.pathFillRules[c.id] = 0;
+                break;
+            }
+            case RenderCmd::makePaint:
+            {
+                auto c = reader.read<MakeIdPOD>();
+                paints.set(c.id, factory->makeRenderPaint(), c.generation);
+                if (c.id >= table.paintShadows.size())
+                {
+                    table.paintShadows.resize(c.id + 1);
+                }
+                table.paintShadows[c.id] = PaintShadow{};
+                break;
+            }
+            case RenderCmd::makeLinearGradient:
+            {
+                auto c = reader.read<LinearGradientPOD>();
+                const ColorInt* colors = reinterpret_cast<const ColorInt*>(
+                    reader
+                        .blobAt(c.blobOffset,
+                                c.count *
+                                    static_cast<uint32_t>(sizeof(ColorInt)))
+                        .data());
+                const float* stops = reinterpret_cast<const float*>(
+                    reader
+                        .blobAt(c.stopsOffset,
+                                c.count * static_cast<uint32_t>(sizeof(float)))
+                        .data());
+                if (colors == nullptr || stops == nullptr)
+                {
+                    break; // blob out of range (corrupt stream)
+                }
+                shaders.set(c.id,
+                            factory->makeLinearGradient(c.sx,
+                                                        c.sy,
+                                                        c.ex,
+                                                        c.ey,
+                                                        colors,
+                                                        stops,
+                                                        c.count),
+                            c.generation);
+                break;
+            }
+            case RenderCmd::makeRadialGradient:
+            {
+                auto c = reader.read<RadialGradientPOD>();
+                const ColorInt* colors = reinterpret_cast<const ColorInt*>(
+                    reader
+                        .blobAt(c.blobOffset,
+                                c.count *
+                                    static_cast<uint32_t>(sizeof(ColorInt)))
+                        .data());
+                const float* stops = reinterpret_cast<const float*>(
+                    reader
+                        .blobAt(c.stopsOffset,
+                                c.count * static_cast<uint32_t>(sizeof(float)))
+                        .data());
+                if (colors == nullptr || stops == nullptr)
+                {
+                    break; // blob out of range (corrupt stream)
+                }
+                shaders.set(c.id,
+                            factory->makeRadialGradient(c.cx,
+                                                        c.cy,
+                                                        c.radius,
+                                                        colors,
+                                                        stops,
+                                                        c.count),
+                            c.generation);
+                break;
+            }
+            case RenderCmd::decodeImage:
+            {
+                auto c = reader.read<DecodeImagePOD>();
+                images.set(c.id,
+                           factory->decodeImage(
+                               reader.blobAt(c.blobOffset, c.byteCount)),
+                           c.generation);
+                break;
+            }
+            case RenderCmd::makeBuffer:
+            {
+                auto c = reader.read<MakeBufferPOD>();
+                buffers.set(c.id,
+                            factory->makeRenderBuffer(
+                                static_cast<RenderBufferType>(c.bufferType),
+                                static_cast<RenderBufferFlags>(c.flags),
+                                c.sizeInBytes),
+                            c.generation);
+                if (c.id >= table.bufferShadows.size())
+                {
+                    table.bufferShadows.resize(c.id + 1);
+                }
+                table.bufferShadows[c.id] = {static_cast<uint8_t>(c.bufferType),
+                                             static_cast<uint16_t>(c.flags),
+                                             c.sizeInBytes};
+                break;
+            }
+            case RenderCmd::bufferData:
+            {
+                auto c = reader.read<BufferDataPOD>();
+                Span<const uint8_t> src = reader.blobAt(c.blobOffset, c.size);
+                if (auto* b = buffers.get(c.buffer))
+                {
+                    if (src.size() == c.size)
+                    {
+                        void* dst = b->map();
+                        if (dst)
+                        {
+                            std::memcpy(dst, src.data(), c.size);
+                        }
+                        b->unmap();
+                    }
+                }
+                break;
+            }
+            case RenderCmd::destroyResource:
+            {
+                auto c = reader.read<DestroyResourcePOD>();
+                table.destroy(static_cast<ResourceKind>(c.kind),
+                              c.id,
+                              c.generation);
+                break;
+            }
+            case RenderCmd::resourceNewVersion:
+            {
+                // A drawn resource was mutated again: alias the outgoing
+                // version for the draws that pinned it and continue on a
+                // fresh object carrying the shadowed state.
+                auto c = reader.read<ResourceVersionPOD>();
+                switch (static_cast<ResourceKind>(c.kind))
+                {
+                    case ResourceKind::paint:
+                    {
+                        auto fresh = factory->makeRenderPaint();
+                        if (fresh != nullptr &&
+                            c.id < table.paintShadows.size())
+                        {
+                            const PaintShadow& sh = table.paintShadows[c.id];
+                            fresh->style(
+                                static_cast<RenderPaintStyle>(sh.style));
+                            fresh->color(sh.color);
+                            fresh->thickness(sh.thickness);
+                            fresh->join(static_cast<StrokeJoin>(sh.join));
+                            fresh->cap(static_cast<StrokeCap>(sh.cap));
+                            fresh->feather(sh.feather);
+                            fresh->blendMode(
+                                static_cast<BlendMode>(sh.blendMode));
+                            if (sh.shader != kInvalidRenderHandle)
+                            {
+                                fresh->shader(shaders.shared(sh.shader));
+                            }
+                        }
+                        paints.newVersion(c.id, c.version, std::move(fresh));
+                        break;
+                    }
+                    case ResourceKind::path:
+                    {
+                        // Seed from the outgoing version so a non rewind
+                        // mutation appends onto prior geometry; a rewind bump
+                        // clears the seed via its own recorded command.
+                        auto fresh = factory->makeEmptyRenderPath();
+                        if (fresh != nullptr)
+                        {
+                            if (auto* outgoing = paths.get(c.id))
+                            {
+                                fresh->addRenderPath(outgoing, Mat2D());
+                            }
+                            if (c.id < table.pathFillRules.size())
+                            {
+                                fresh->fillRule(static_cast<FillRule>(
+                                    table.pathFillRules[c.id]));
+                            }
+                        }
+                        paths.newVersion(c.id, c.version, std::move(fresh));
+                        break;
+                    }
+                    case ResourceKind::buffer:
+                    {
+                        rcp<RenderBuffer> fresh;
+                        if (c.id < table.bufferShadows.size())
+                        {
+                            const BufferShadow& sh = table.bufferShadows[c.id];
+                            fresh = factory->makeRenderBuffer(
+                                static_cast<RenderBufferType>(sh.type),
+                                static_cast<RenderBufferFlags>(sh.flags),
+                                sh.size);
+                        }
+                        buffers.newVersion(c.id, c.version, std::move(fresh));
+                        break;
+                    }
+                    default:
+                        break; // shaders and images never mutate
+                }
+                break;
+            }
+
+            case RenderCmd::pathRewind:
+            {
+                auto c = reader.read<ResIdPOD>();
+                if (auto* p = path(c.id))
+                {
+                    p->rewind();
+                }
+                break;
+            }
+            case RenderCmd::pathFillRule:
+            {
+                auto c = reader.read<PathFillRulePOD>();
+                if (auto* p = path(c.path))
+                {
+                    p->fillRule(static_cast<FillRule>(c.fillRule));
+                    table.pathFillRules[c.path] = c.fillRule;
+                }
+                break;
+            }
+            case RenderCmd::pathAddRawPath:
+            {
+                auto c = reader.read<PathRawPOD>();
+                RawPath raw = rebuildRawPath(
+                    reader.blobAt(c.blobOffset,
+                                  c.verbCount *
+                                      static_cast<uint32_t>(sizeof(PathVerb))),
+                    reader.blobAt(c.pointsOffset,
+                                  c.pointCount *
+                                      static_cast<uint32_t>(sizeof(Vec2D))));
+                if (auto* p = path(c.path))
+                {
+                    p->addRawPath(raw);
+                }
+                break;
+            }
+            case RenderCmd::pathAddRenderPath:
+            {
+                auto c = reader.read<PathAddPathPOD>();
+                RenderPath* src = paths.get(c.src);
+                if (auto* p = path(c.path))
+                {
+                    if (src)
+                        p->addRenderPath(
+                            src,
+                            Mat2D(c.xx, c.xy, c.yx, c.yy, c.tx, c.ty));
+                }
+                break;
+            }
+
+            case RenderCmd::paintStyle:
+            {
+                auto c = reader.read<PaintU8POD>();
+                if (auto* pt = paint(c.paint))
+                {
+                    pt->style(static_cast<RenderPaintStyle>(c.value));
+                    table.paintShadows[c.paint].style = c.value;
+                }
+                break;
+            }
+            case RenderCmd::paintColor:
+            {
+                auto c = reader.read<PaintColorPOD>();
+                if (auto* pt = paint(c.paint))
+                {
+                    pt->color(c.color);
+                    table.paintShadows[c.paint].color = c.color;
+                }
+                break;
+            }
+            case RenderCmd::paintThickness:
+            {
+                auto c = reader.read<PaintFloatPOD>();
+                if (auto* pt = paint(c.paint))
+                {
+                    pt->thickness(c.value);
+                    table.paintShadows[c.paint].thickness = c.value;
+                }
+                break;
+            }
+            case RenderCmd::paintJoin:
+            {
+                auto c = reader.read<PaintU8POD>();
+                if (auto* pt = paint(c.paint))
+                {
+                    pt->join(static_cast<StrokeJoin>(c.value));
+                    table.paintShadows[c.paint].join = c.value;
+                }
+                break;
+            }
+            case RenderCmd::paintCap:
+            {
+                auto c = reader.read<PaintU8POD>();
+                if (auto* pt = paint(c.paint))
+                {
+                    pt->cap(static_cast<StrokeCap>(c.value));
+                    table.paintShadows[c.paint].cap = c.value;
+                }
+                break;
+            }
+            case RenderCmd::paintFeather:
+            {
+                auto c = reader.read<PaintFloatPOD>();
+                if (auto* pt = paint(c.paint))
+                {
+                    pt->feather(c.value);
+                    table.paintShadows[c.paint].feather = c.value;
+                }
+                break;
+            }
+            case RenderCmd::paintBlendMode:
+            {
+                auto c = reader.read<PaintU8POD>();
+                if (auto* pt = paint(c.paint))
+                {
+                    pt->blendMode(static_cast<BlendMode>(c.value));
+                    table.paintShadows[c.paint].blendMode = c.value;
+                }
+                break;
+            }
+            case RenderCmd::paintShader:
+            {
+                auto c = reader.read<PaintShaderPOD>();
+                if (auto* pt = paint(c.paint))
+                {
+                    pt->shader(shaders.shared(c.shader));
+                    table.paintShadows[c.paint].shader = c.shader;
+                }
+                break;
+            }
+            case RenderCmd::paintInvalidateStroke:
+            {
+                auto c = reader.read<ResIdPOD>();
+                if (auto* pt = paint(c.id))
+                {
+                    pt->invalidateStroke();
+                }
+                break;
+            }
+
+            case RenderCmd::save:
+                if (cur)
+                {
+                    cur->save();
+                }
+                break;
+            case RenderCmd::restore:
+                if (cur)
+                {
+                    cur->restore();
+                }
+                break;
+            case RenderCmd::transform:
+            {
+                auto c = reader.read<TransformPOD>();
+                if (cur)
+                {
+                    cur->transform(Mat2D(c.xx, c.xy, c.yx, c.yy, c.tx, c.ty));
+                }
+                break;
+            }
+            case RenderCmd::drawPath:
+            {
+                auto c = reader.read<DrawPathPOD>();
+                RenderPath* p = paths.get(c.path, c.pathVersion);
+                RenderPaint* pt = paints.get(c.paint, c.paintVersion);
+                if (cur && p && pt)
+                {
+                    cur->drawPath(p, pt);
+                }
+                else if (cur != nullptr && hooks.stats != nullptr)
+                {
+                    hooks.stats->droppedDraws = hooks.stats->droppedDraws + 1;
+                    replay_detail::logDroppedDraw(type, c.path, c.paint);
+                }
+                break;
+            }
+            case RenderCmd::clipPath:
+            {
+                auto c = reader.read<ClipPathPOD>();
+                if (cur)
+                {
+                    if (auto* p = paths.get(c.path, c.version))
+                        cur->clipPath(p);
+                }
+                break;
+            }
+            case RenderCmd::drawImage:
+            {
+                auto c = reader.read<DrawImagePOD>();
+                RenderImage* im =
+                    (c.image & kCanvasHandleFlag)
+                        ? (hooks.canvasImage
+                               ? hooks.canvasImage(c.image & kCanvasHandleMask)
+                               : nullptr)
+                        : image(c.image);
+                if (cur && im)
+                {
+                    cur->drawImage(im,
+                                   sampler(c.wrapX, c.wrapY, c.filter),
+                                   static_cast<BlendMode>(c.blendMode),
+                                   c.opacity);
+                }
+                else if (cur != nullptr && hooks.stats != nullptr)
+                {
+                    hooks.stats->droppedDraws = hooks.stats->droppedDraws + 1;
+                    replay_detail::logDroppedDraw(type, c.image, 0);
+                }
+                break;
+            }
+            case RenderCmd::drawImageMesh:
+            {
+                auto c = reader.read<DrawImageMeshPOD>();
+                RenderImage* im =
+                    (c.image & kCanvasHandleFlag)
+                        ? (hooks.canvasImage
+                               ? hooks.canvasImage(c.image & kCanvasHandleMask)
+                               : nullptr)
+                        : image(c.image);
+                rcp<RenderBuffer> vb =
+                    buffers.shared(c.vertices, c.vertexVersion);
+                rcp<RenderBuffer> uv = buffers.shared(c.uvCoords, c.uvVersion);
+                rcp<RenderBuffer> ib =
+                    buffers.shared(c.indices, c.indexVersion);
+                if (cur && im && vb && uv && ib)
+                {
+                    cur->drawImageMesh(im,
+                                       sampler(c.wrapX, c.wrapY, c.filter),
+                                       vb,
+                                       uv,
+                                       ib,
+                                       c.vertexCount,
+                                       c.indexCount,
+                                       static_cast<BlendMode>(c.blendMode),
+                                       c.opacity);
+                }
+                else if (cur != nullptr && hooks.stats != nullptr)
+                {
+                    hooks.stats->droppedDraws = hooks.stats->droppedDraws + 1;
+                    replay_detail::logDroppedDraw(type, c.image, 0);
+                }
+                break;
+            }
+            case RenderCmd::modulateOpacity:
+            {
+                auto c = reader.read<OpacityPOD>();
+                if (cur)
+                {
+                    cur->modulateOpacity(c.opacity);
+                }
+                break;
+            }
+
+            case RenderCmd::canvasContentBegin:
+            {
+                auto c = reader.read<CanvasContentPOD>();
+                cur = hooks.beginCanvasContent
+                          ? hooks.beginCanvasContent(c.canvasId &
+                                                         kCanvasHandleMask,
+                                                     c.clearColor)
+                          : nullptr;
+                break;
+            }
+            case RenderCmd::canvasContentEnd:
+            {
+                reader.read<ResIdPOD>(); // advance past the canvas id
+                cur = renderer;          // back to the screen; null drops draws
+                break;
+            }
+        }
+    }
+    if (reader.overrun())
+    {
+        fprintf(stderr,
+                "rive replay ABORT: payload overrun at byte %zu of %zu\n",
+                reader.position(),
+                commands.size());
+        assert(false);
+    }
+}
+
+} // namespace rive::cmd
diff --git a/renderer/src/gl/gl_utils.cpp b/renderer/src/gl/gl_utils.cpp
index 48a932a..9502798 100644
--- a/renderer/src/gl/gl_utils.cpp
+++ b/renderer/src/gl/gl_utils.cpp
@@ -3,6 +3,7 @@
  */
 
 #include "rive/renderer/gl/gl_utils.hpp"
+#include "rive/rive_types.hpp"
 #include "rive/shapes/paint/image_sampler.hpp"
 
 #include <stdio.h>
@@ -12,6 +13,13 @@
 
 #include "generated/shaders/glsl.glsl.hpp"
 
+#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT
+#include <atomic>
+#include <mutex>
+#include <unordered_map>
+#include <emscripten/html5.h>
+#endif
+
 #ifdef BYPASS_EMSCRIPTEN_SHADER_PARSER
 #include <emscripten/emscripten.h>
 #include <emscripten/html5.h>
@@ -31,6 +39,137 @@
 
 namespace glutils
 {
+static void delete_name(GLObjectType type, GLuint id)
+{
+    switch (type)
+    {
+        case GLObjectType::buffer:
+            glDeleteBuffers(1, &id);
+            return;
+        case GLObjectType::texture:
+            glDeleteTextures(1, &id);
+            return;
+        case GLObjectType::framebuffer:
+            glDeleteFramebuffers(1, &id);
+            return;
+        case GLObjectType::renderbuffer:
+            glDeleteRenderbuffers(1, &id);
+            return;
+        case GLObjectType::vertexArray:
+            glDeleteVertexArrays(1, &id);
+            return;
+        case GLObjectType::shader:
+            glDeleteShader(id);
+            return;
+        case GLObjectType::program:
+            glDeleteProgram(id);
+            return;
+    }
+    RIVE_UNREACHABLE();
+}
+
+#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT
+namespace
+{
+struct AbandonedName
+{
+    GLObjectType type;
+    GLuint id;
+};
+
+std::mutex g_abandonedMutex;
+std::unordered_map<GLContextID, std::vector<AbandonedName>> g_abandonedNames;
+std::atomic<uint32_t> g_abandonedCount{0};
+std::atomic<uint32_t> g_reclaimedCount{0};
+} // namespace
+
+GLContextID CurrentContextID()
+{
+    return static_cast<GLContextID>(emscripten_webgl_get_current_context());
+}
+
+static void abandon_name(GLObjectType type, GLuint id, GLContextID owner)
+{
+    {
+        std::lock_guard<std::mutex> lock(g_abandonedMutex);
+        g_abandonedNames[owner].push_back({type, id});
+    }
+    g_abandonedCount.fetch_add(1, std::memory_order_relaxed);
+}
+
+void ReclaimAbandonedNames()
+{
+    if (g_abandonedCount.load(std::memory_order_acquire) ==
+        g_reclaimedCount.load(std::memory_order_relaxed))
+    {
+        return;
+    }
+    std::vector<AbandonedName> mine;
+    {
+        std::lock_guard<std::mutex> lock(g_abandonedMutex);
+        auto it = g_abandonedNames.find(CurrentContextID());
+        if (it == g_abandonedNames.end())
+        {
+            return;
+        }
+        mine.swap(it->second);
+        g_abandonedNames.erase(it);
+    }
+    for (const AbandonedName& name : mine)
+    {
+        delete_name(name.type, name.id);
+    }
+    g_reclaimedCount.fetch_add(static_cast<uint32_t>(mine.size()),
+                               std::memory_order_release);
+}
+
+uint32_t AbandonedNameCount()
+{
+    return g_abandonedCount.load(std::memory_order_relaxed);
+}
+
+uint32_t ReclaimedNameCount()
+{
+    return g_reclaimedCount.load(std::memory_order_relaxed);
+}
+#endif
+
+void GLObject::destroy(GLObjectType type)
+{
+    if (m_id == 0)
+    {
+        return;
+    }
+#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT
+    if (m_context != CurrentContextID())
+    {
+        abandon_name(type, m_id, m_context);
+        m_id = 0;
+        return;
+    }
+#endif
+    delete_name(type, m_id);
+    m_id = 0;
+}
+
+void GLObject::adopt(GLObjectType type, GLObject&& rhs)
+{
+    destroy(type);
+    m_id = std::exchange(rhs.m_id, 0);
+#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT
+    m_context = rhs.m_context;
+#endif
+}
+
+void GLObject::adoptName(GLObjectType type, GLuint adoptedID)
+{
+    destroy(type);
+    m_id = adoptedID;
+#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT
+    m_context = CurrentContextID();
+#endif
+}
+
 void CompileAndAttachShader(GLuint program,
                             GLenum type,
                             const char* source,
@@ -242,17 +381,6 @@
 #endif
 }
 
-void Program::reset(GLuint adoptedProgramID)
-{
-    m_fragmentShader.reset();
-    m_vertexShader.reset();
-    if (m_id != 0)
-    {
-        glDeleteProgram(m_id);
-    }
-    m_id = adoptedProgramID;
-}
-
 void Program::compileAndAttachShader(GLuint type,
                                      const char* defines[],
                                      size_t numDefines,
diff --git a/renderer/src/gl/render_context_gl_impl.cpp b/renderer/src/gl/render_context_gl_impl.cpp
index 1177ff3..017b7c4 100644
--- a/renderer/src/gl/render_context_gl_impl.cpp
+++ b/renderer/src/gl/render_context_gl_impl.cpp
@@ -686,6 +686,9 @@
             static_cast<uintptr_t>(static_cast<GLuint>(m_texture)));
     }
 
+    // Lets deferred replay back a canvas with a worker context texture.
+    void setGLTexture(GLuint id) { m_texture = glutils::Texture::Adopt(id); }
+
 protected:
     glutils::Texture m_texture;
 };
@@ -714,10 +717,29 @@
     {
         if (m_owner != nullptr)
         {
-            m_owner->unregisterCanvasTarget(m_glID);
+            m_owner->releaseCanvasTarget(m_glID);
         }
     }
 
+    // Deferred replay backs an id 0 canvas with a worker texture so all reads
+    // resolve coherently on the worker. The registry entry for that texture
+    // belongs to the context that allocated it, which on threaded web is the
+    // worker's impl and not the producer this texture was constructed with, so
+    // the owner moves with the backing. Otherwise the destructor unregisters
+    // from the producer: the worker keeps a stale entry wrapRiveTexture can
+    // resurrect, and the producer loses whatever it held under the same GL
+    // name, GL names being per context and both starting from 1.
+    void rebindBacking(RenderContextGLImpl* owner, GLuint id)
+    {
+        if (m_owner != nullptr && m_glID != 0)
+        {
+            m_owner->releaseCanvasTarget(m_glID);
+        }
+        setGLTexture(id);
+        m_owner = owner;
+        m_glID = id;
+    }
+
 private:
     RenderContextGLImpl* m_owner;
     GLuint m_glID;
@@ -875,15 +897,10 @@
 }
 
 #ifdef RIVE_CANVAS
-rcp<RenderCanvas> RenderContextGLImpl::makeRenderCanvas(uint32_t width,
-                                                        uint32_t height)
+rcp<RenderCanvas> RenderContextGLImpl::wrapCanvasBacking(uint32_t width,
+                                                         uint32_t height,
+                                                         GLuint tex)
 {
-    GLuint tex;
-    glGenTextures(1, &tex);
-    glActiveTexture(GL_TEXTURE0);
-    glBindTexture(GL_TEXTURE_2D, tex);
-    glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height);
-
     // Wrap as a CanvasSourceTextureGLImpl so the registry entry is
     // unregistered automatically when the source texture is destroyed.
     // The texture takes ownership of `tex` (RAII via glutils::Texture).
@@ -900,6 +917,21 @@
     auto renderTarget = make_rcp<TextureRenderTargetGL>(width, height);
     renderTarget->setTargetTexture(tex);
 
+    return make_rcp<RenderCanvas>(std::move(renderImage),
+                                  std::move(renderTarget));
+}
+
+rcp<RenderCanvas> RenderContextGLImpl::makeRenderCanvas(uint32_t width,
+                                                        uint32_t height)
+{
+    GLuint tex;
+    glGenTextures(1, &tex);
+    glActiveTexture(GL_TEXTURE0);
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height);
+
+    auto canvas = wrapCanvasBacking(width, height, tex);
+
     // GL renders into the canvas with row 0 = visual bottom (framebuffer
     // bottom-up convention). Register the source GLuint with the mirror
     // registry so wrapRiveTexture (ore_context_gl.cpp) can detect it
@@ -909,13 +941,47 @@
     // See dev/ore_canvas_import_invariant.md.
     registerCanvasTarget(tex);
 
-    return make_rcp<RenderCanvas>(std::move(renderImage),
-                                  std::move(renderTarget));
+    return canvas;
+}
+
+rcp<RenderCanvas> RenderContextGLImpl::makeDeferredRenderCanvas(uint32_t width,
+                                                                uint32_t height)
+{
+    // No GPU work here; the replay worker owns the canvas texture, backs it
+    // on first use, and registers the mirror target then.
+    return wrapCanvasBacking(width, height, 0);
+}
+
+void RenderContextGLImpl::ensureDeferredCanvasBacking(gpu::RenderCanvas* canvas)
+{
+    // Set the texture on both the render target and image source so every
+    // read resolves coherently here.
+    auto* rt = static_cast<gpu::TextureRenderTargetGL*>(canvas->renderTarget());
+    if (rt->externalTextureID() != 0)
+    {
+        return;
+    }
+
+    GLuint tex;
+    glGenTextures(1, &tex);
+    glActiveTexture(GL_TEXTURE0);
+    glBindTexture(GL_TEXTURE_2D, tex);
+    glTexStorage2D(GL_TEXTURE_2D,
+                   1,
+                   GL_RGBA8,
+                   canvas->width(),
+                   canvas->height());
+    glBindTexture(GL_TEXTURE_2D, 0);
+
+    rt->setTargetTexture(tex);
+    static_cast<CanvasSourceTextureGLImpl*>(canvas->renderImage()->getTexture())
+        ->rebindBacking(this, tex);
+    registerCanvasTarget(tex);
 }
 
 std::unique_ptr<rive::ore::Context> RenderContextGLImpl::makeOreContext()
 {
-    return rive::ore::ContextGL::Make();
+    return rive::ore::ContextGL::Make(this);
 }
 
 // ────────────────────────────────────────────────────────────────────────────
@@ -971,6 +1037,36 @@
     m_canvasMirrors.erase(it);
 }
 
+void RenderContextGLImpl::releaseCanvasTarget(GLuint sourceTex)
+{
+    if (m_glContext == glutils::CurrentContextID())
+    {
+        unregisterCanvasTarget(sourceTex);
+        return;
+    }
+    std::lock_guard<std::mutex> lock(m_releasedCanvasTargetMutex);
+    m_releasedCanvasTargets.push_back(sourceTex);
+    m_hasReleasedCanvasTargets.store(true, std::memory_order_release);
+}
+
+void RenderContextGLImpl::drainReleasedCanvasTargets()
+{
+    if (!m_hasReleasedCanvasTargets.load(std::memory_order_acquire))
+    {
+        return;
+    }
+    std::vector<GLuint> released;
+    {
+        std::lock_guard<std::mutex> lock(m_releasedCanvasTargetMutex);
+        released.swap(m_releasedCanvasTargets);
+        m_hasReleasedCanvasTargets.store(false, std::memory_order_release);
+    }
+    for (GLuint sourceTex : released)
+    {
+        unregisterCanvasTarget(sourceTex);
+    }
+}
+
 rcp<RiveRenderImage> RenderContextGLImpl::getOrCreateCanvasMirror(
     GLuint sourceTex,
     uint32_t width,
@@ -2320,6 +2416,13 @@
     assert(desc.interlockMode != gpu::InterlockMode::clockwiseAtomic);
     auto renderTarget = static_cast<RenderTargetGL*>(desc.renderTarget);
 
+    // This context is current on its own thread here, the only place names it
+    // owns can be deleted.
+#ifdef RIVE_CANVAS
+    drainReleasedCanvasTargets();
+#endif
+    glutils::ReclaimAbandonedNames();
+
     // All programs use the same set of per-flush uniforms.
     glBindBufferRange(GL_UNIFORM_BUFFER,
                       FLUSH_UNIFORM_BUFFER_IDX,
diff --git a/renderer/src/metal/render_context_metal_impl.mm b/renderer/src/metal/render_context_metal_impl.mm
index 19b82cf..d1cae74 100644
--- a/renderer/src/metal/render_context_metal_impl.mm
+++ b/renderer/src/metal/render_context_metal_impl.mm
@@ -1015,7 +1015,12 @@
 
 std::unique_ptr<rive::ore::Context> RenderContextMetalImpl::makeOreContext()
 {
-    assert(m_commandQueue);
+    // A deferred session can request the ore context before the first render
+    // texture lazily sets the command queue, so mint one here.
+    if (m_commandQueue == nil)
+    {
+        m_commandQueue = [m_gpu newCommandQueue];
+    }
     return rive::ore::ContextMetal::Make(m_gpu, m_commandQueue);
 }
 #endif
diff --git a/renderer/src/ore/d3d11/ore_context_d3d11.cpp b/renderer/src/ore/d3d11/ore_context_d3d11.cpp
index 50490c8..60c12f9 100644
--- a/renderer/src/ore/d3d11/ore_context_d3d11.cpp
+++ b/renderer/src/ore/d3d11/ore_context_d3d11.cpp
@@ -1401,6 +1401,22 @@
         new TextureViewD3D11(std::move(texture), viewDesc));
     // Borrow the existing RTV from the D3D render target (AddRefs via ComPtr).
     view->m_d3dRTV = d3dTarget->targetRTV();
+
+    // SRV so a later pass can sample the canvas after rendering into it;
+    // without it a bind group samples an unbound view and reads black.
+    D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc{};
+    srvDesc.Format = d3dDesc.Format;
+    srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
+    srvDesc.Texture2D.MipLevels = 1;
+    srvDesc.Texture2D.MostDetailedMip = 0;
+    ComPtr<ID3D11Device> device;
+    m_d3d11Context->GetDevice(device.GetAddressOf());
+    if (FAILED(device->CreateShaderResourceView(
+            d3dTex,
+            &srvDesc,
+            view->m_d3dSRV.ReleaseAndGetAddressOf())))
+        return nullptr;
+
     return view;
 }
 
diff --git a/renderer/src/ore/d3d11/ore_render_pass_d3d11.cpp b/renderer/src/ore/d3d11/ore_render_pass_d3d11.cpp
index d17aa53..d7e43d5 100644
--- a/renderer/src/ore/d3d11/ore_render_pass_d3d11.cpp
+++ b/renderer/src/ore/d3d11/ore_render_pass_d3d11.cpp
@@ -128,6 +128,13 @@
 {
     validate();
     auto* pipeline = static_cast<PipelineD3D11*>(inPipeline);
+    // A recompile or play stop can destroy the pipeline under a straddling
+    // deferred frame; drop the bind so draw skips instead of dereferencing.
+    if (pipeline == nullptr)
+    {
+        m_currentPipeline = nullptr;
+        return;
+    }
     if (!checkPipelineCompat(pipeline))
         return;
     m_currentPipeline = ref_rcp(pipeline);
@@ -155,6 +162,8 @@
 {
     validate();
     auto buffer = static_cast<BufferD3D11*>(inBuffer);
+    if (buffer == nullptr) // destroyed under a straddling deferred frame
+        return;
     UINT stride = (m_currentPipeline &&
                    slot < m_currentPipeline->desc().vertexBufferCount)
                       ? m_currentPipeline->desc().vertexBuffers[slot].stride
@@ -170,6 +179,8 @@
 {
     validate();
     auto buffer = static_cast<BufferD3D11*>(inBuffer);
+    if (buffer == nullptr) // destroyed under a straddling deferred frame
+        return;
     m_d3d11IndexFormat = oreIndexFormatToDXGI(format);
     m_d3d11IndexOffset = offset;
     m_d3d11Context->IASetIndexBuffer(buffer->m_d3d11Buffer.Get(),
@@ -184,7 +195,8 @@
 {
     validate();
     auto bg = static_cast<BindGroupD3D11*>(inBg);
-    assert(bg != nullptr);
+    if (bg == nullptr) // destroyed under a straddling deferred frame
+        return;
 
     // Hold a strong reference so the BindGroup stays alive until finish().
     m_boundGroups[groupIndex] = ref_rcp(bg);
@@ -363,6 +375,8 @@
                            uint32_t firstInstance)
 {
     validate();
+    if (m_currentPipeline == nullptr) // dropped under a straddling frame
+        return;
     if (instanceCount > 1 || firstInstance != 0)
     {
         m_d3d11Context->DrawInstanced(vertexCount,
@@ -383,6 +397,8 @@
                                   uint32_t firstInstance)
 {
     validate();
+    if (m_currentPipeline == nullptr) // dropped under a straddling frame
+        return;
     if (instanceCount > 1 || firstInstance != 0 || baseVertex != 0)
     {
         m_d3d11Context->DrawIndexedInstanced(indexCount,
diff --git a/renderer/src/ore/d3d12/ore_context_d3d12.cpp b/renderer/src/ore/d3d12/ore_context_d3d12.cpp
index a340a2e..deaf5ce 100644
--- a/renderer/src/ore/d3d12/ore_context_d3d12.cpp
+++ b/renderer/src/ore/d3d12/ore_context_d3d12.cpp
@@ -584,6 +584,9 @@
     ID3D12DescriptorHeap* heaps[] = {m_d3dGpuSrvHeap.Get(),
                                      m_d3dGpuSamplerHeap.Get()};
     m_d3dCmdList->SetDescriptorHeaps(2, heaps);
+
+    // Record uploads staged while no frame was open onto this frame's list.
+    d3d12FlushPendingTextureUploads();
 #endif
 }
 
@@ -591,6 +594,76 @@
 
 void ContextD3D12::endFrame() {}
 
+#if defined(ORE_BACKEND_D3D12)
+void ContextD3D12::d3d12QueuePendingTextureUpload(
+    D3D12PendingTextureUpload pending)
+{
+    m_d3dPendingUploads.push_back(std::move(pending));
+}
+
+void ContextD3D12::d3d12FlushPendingTextureUploads()
+{
+    if (m_d3dPendingUploads.empty())
+        return;
+    // Only reachable with a live list: beginFrame and beginRenderPass drain.
+    assert(m_d3dCmdList != nullptr);
+
+    for (auto& pu : m_d3dPendingUploads)
+    {
+        auto* tex = lite_rtti_cast<TextureD3D12*>(pu.texture.get());
+        auto* staging = lite_rtti_cast<BufferD3D12*>(pu.staging.get());
+        if (tex == nullptr || staging == nullptr ||
+            tex->m_d3dTexture == nullptr)
+            continue;
+
+        if (tex->m_d3dCurrentState != D3D12_RESOURCE_STATE_COPY_DEST)
+        {
+            D3D12_RESOURCE_BARRIER barrier = {};
+            barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
+            barrier.Transition.pResource = tex->m_d3dTexture.Get();
+            barrier.Transition.StateBefore = tex->m_d3dCurrentState;
+            barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_COPY_DEST;
+            barrier.Transition.Subresource =
+                D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
+            m_d3dCmdList->ResourceBarrier(1, &barrier);
+            tex->m_d3dCurrentState = D3D12_RESOURCE_STATE_COPY_DEST;
+        }
+
+        D3D12_TEXTURE_COPY_LOCATION dstLoc = {};
+        dstLoc.pResource = tex->m_d3dTexture.Get();
+        dstLoc.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX;
+        dstLoc.SubresourceIndex = pu.subresource;
+
+        D3D12_TEXTURE_COPY_LOCATION srcLoc = {};
+        srcLoc.pResource = staging->m_d3dBuffer.Get();
+        srcLoc.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT;
+        srcLoc.PlacedFootprint = pu.footprint;
+
+        m_d3dCmdList->CopyTextureRegion(&dstLoc,
+                                        pu.dstX,
+                                        pu.dstY,
+                                        pu.dstZ,
+                                        &srcLoc,
+                                        nullptr);
+
+        // Leave it sample-ready so render passes need no explicit barrier.
+        D3D12_RESOURCE_BARRIER barrier = {};
+        barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
+        barrier.Transition.pResource = tex->m_d3dTexture.Get();
+        barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST;
+        barrier.Transition.StateAfter =
+            D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
+        barrier.Transition.Subresource =
+            D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
+        m_d3dCmdList->ResourceBarrier(1, &barrier);
+        tex->m_d3dCurrentState = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
+    }
+
+    // Purgatory keeps the staging buffers alive until safeFrameNumber.
+    m_d3dPendingUploads.clear();
+}
+#endif
+
 // ============================================================================
 // d3d12FlushUploads + GPU-visible heap allocation helpers (called by
 // RenderPass and d3d12* helpers)
@@ -1551,6 +1624,9 @@
     std::string* outError)
 {
 #if defined(ORE_BACKEND_D3D12)
+    // Drain uploads staged mid-frame before the pass reads the textures.
+    d3d12FlushPendingTextureUploads();
+
     std::unique_ptr<RenderPassD3D12> pass(new RenderPassD3D12(this));
     pass->m_d3dCmdList = m_d3dCmdList;
     pass->m_d3dDevice = m_d3dDevice.Get();
@@ -1801,6 +1877,28 @@
     auto view = rcp<TextureViewD3D12>(
         new TextureViewD3D12(m_manager, std::move(texture), viewDesc));
 
+    // SRV so a later pass can sample the canvas after rendering into it;
+    // without it a bind group copies a null descriptor and the debug layer
+    // faults.
+    if (m_d3dCpuSrvAllocated < 1024)
+    {
+        D3D12_CPU_DESCRIPTOR_HANDLE srvHandle =
+            m_d3dCpuSrvHeap->GetCPUDescriptorHandleForHeapStart();
+        srvHandle.ptr += (SIZE_T)m_d3dCpuSrvAllocated++ * m_d3dSrvDescSize;
+
+        D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
+        srvDesc.Format = dxgiFmt;
+        srvDesc.Shader4ComponentMapping =
+            D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
+        srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
+        srvDesc.Texture2D.MipLevels = 1;
+
+        m_d3dDevice->CreateShaderResourceView(d3dTex->resource(),
+                                              &srvDesc,
+                                              srvHandle);
+        view->m_d3dSrvHandle = srvHandle;
+    }
+
     // Create the RTV in our CPU RTV heap.
     D3D12_CPU_DESCRIPTOR_HANDLE handle =
         m_d3dCpuRtvHeap->GetCPUDescriptorHandleForHeapStart();
diff --git a/renderer/src/ore/d3d12/ore_texture_d3d12.cpp b/renderer/src/ore/d3d12/ore_texture_d3d12.cpp
index 1d3c710..c905483 100644
--- a/renderer/src/ore/d3d12/ore_texture_d3d12.cpp
+++ b/renderer/src/ore/d3d12/ore_texture_d3d12.cpp
@@ -214,26 +214,6 @@
     }
     m_uploadBuffer->m_d3dBuffer->Unmap(0, nullptr);
 
-    // If the texture is not in COPY_DEST state, transition it.
-    if (m_d3dCurrentState != D3D12_RESOURCE_STATE_COPY_DEST)
-    {
-        D3D12_RESOURCE_BARRIER barrier = {};
-        barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
-        barrier.Transition.pResource = m_d3dTexture.Get();
-        barrier.Transition.StateBefore = m_d3dCurrentState;
-        barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_COPY_DEST;
-        barrier.Transition.Subresource =
-            D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
-        ctx->m_d3dCmdList->ResourceBarrier(1, &barrier);
-        m_d3dCurrentState = D3D12_RESOURCE_STATE_COPY_DEST;
-    }
-
-    // Copy the whole staged region to (x, y, z). No src box needed.
-    D3D12_TEXTURE_COPY_LOCATION dst_loc = {};
-    dst_loc.pResource = m_d3dTexture.Get();
-    dst_loc.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX;
-    dst_loc.SubresourceIndex = subresource;
-
     D3D12_PLACED_SUBRESOURCE_FOOTPRINT footprint = {};
     footprint.Offset = 0;
     footprint.Footprint.Format = texDesc.Format;
@@ -242,32 +222,17 @@
     footprint.Footprint.Depth = depth;
     footprint.Footprint.RowPitch = static_cast<UINT>(dstRowPitch);
 
-    D3D12_TEXTURE_COPY_LOCATION src_loc = {};
-    src_loc.pResource = m_uploadBuffer->m_d3dBuffer.Get();
-    src_loc.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT;
-    src_loc.PlacedFootprint = footprint;
-
-    ctx->m_d3dCmdList->CopyTextureRegion(&dst_loc,
-                                         data.x,
-                                         data.y,
-                                         data.z,
-                                         &src_loc,
-                                         nullptr);
-
-    // Transition to PIXEL_SHADER_RESOURCE so it's ready to sample without
-    // an explicit barrier in the render pass.
-    {
-        D3D12_RESOURCE_BARRIER barrier = {};
-        barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
-        barrier.Transition.pResource = m_d3dTexture.Get();
-        barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST;
-        barrier.Transition.StateAfter =
-            D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
-        barrier.Transition.Subresource =
-            D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
-        ctx->m_d3dCmdList->ResourceBarrier(1, &barrier);
-        m_d3dCurrentState = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
-    }
+    // Callers stage uploads before an Ore frame, when the host command list is
+    // closed, so queue the copy to record onto a live list once one opens.
+    ctx->d3d12QueuePendingTextureUpload({
+        ref_rcp(this),
+        m_uploadBuffer,
+        footprint,
+        subresource,
+        data.x,
+        data.y,
+        data.z,
+    });
 #else
     (void)data;
 #endif
diff --git a/renderer/src/ore/gl/ore_context_gl.cpp b/renderer/src/ore/gl/ore_context_gl.cpp
index 52da6a4..267f2a9 100644
--- a/renderer/src/ore/gl/ore_context_gl.cpp
+++ b/renderer/src/ore/gl/ore_context_gl.cpp
@@ -13,6 +13,7 @@
 #include "ore_shader_module_gl.hpp"
 #include "ore_texture_gl.hpp"
 #include "rive/renderer/render_canvas.hpp"
+#include "rive/renderer/gl/render_context_gl_impl.hpp"
 #include "rive/rive_types.hpp"
 
 #include <algorithm>
@@ -185,9 +186,9 @@
 
 ContextGL::~ContextGL() {}
 
-std::unique_ptr<ContextGL> ContextGL::Make()
+std::unique_ptr<ContextGL> ContextGL::Make(void* renderContextImpl)
 {
-    auto ctx = std::unique_ptr<ContextGL>(new ContextGL());
+    auto ctx = std::unique_ptr<ContextGL>(new ContextGL(renderContextImpl));
 
     Features& f = ctx->m_features;
 
@@ -296,6 +297,8 @@
 
 void ContextGL::endFrame()
 {
+    // GL uses per pass inline replay, so no whole frame buffer to drain here.
+
     // Restore saved state. Each `RenderPass::finish()` already restores
     // its own captured VAO in-place, so by the time we get here only the
     // program / array-buffer / framebuffer bindings need restoring —
@@ -1147,10 +1150,18 @@
 // wrapCanvasTexture
 // ============================================================================
 
+// During deferred replay the canvas texture from the main context is invalid
+// here, so back the canvas with a worker owned texture first.
 rcp<TextureView> ContextGL::wrapCanvasTexture(gpu::RenderCanvas* canvas)
 {
     assert(canvas != nullptr);
 
+    if (m_renderContextImpl != nullptr)
+    {
+        static_cast<gpu::RenderContextGLImpl*>(m_renderContextImpl)
+            ->ensureDeferredCanvasBacking(canvas);
+    }
+
     auto* glTarget =
         static_cast<gpu::TextureRenderTargetGL*>(canvas->renderTarget());
     GLuint texID = glTarget->externalTextureID();
@@ -1218,4 +1229,47 @@
     return rcp<TextureViewGL>(new TextureViewGL(std::move(texture), viewDesc));
 }
 
+// GL renders the canvas bottom up while WGSL samples top down, so import
+// through a Y flip mirror the view retains to keep the borrowed id valid.
+rcp<TextureView> ContextGL::wrapCanvasSampleView(gpu::RenderCanvas* canvas)
+{
+    assert(canvas != nullptr);
+
+    if (m_renderContextImpl != nullptr)
+    {
+        static_cast<gpu::RenderContextGLImpl*>(m_renderContextImpl)
+            ->ensureDeferredCanvasBacking(canvas);
+    }
+
+    auto* image = canvas->renderImage();
+    gpu::Texture* sourceTex = image->getTexture();
+
+    gpu::Texture* texToWrap = sourceTex;
+    rcp<RenderImage> mirror;
+    if (m_renderContextImpl != nullptr)
+    {
+        auto* glImpl =
+            static_cast<gpu::RenderContextGLImpl*>(m_renderContextImpl);
+        mirror = glImpl->getCanvasImportMirror(sourceTex,
+                                               canvas->width(),
+                                               canvas->height());
+        if (mirror != nullptr)
+        {
+            auto* mirrorRive = lite_rtti_cast<RiveRenderImage*>(mirror.get());
+            if (mirrorRive != nullptr && mirrorRive->getTexture() != nullptr)
+            {
+                texToWrap = mirrorRive->getTexture();
+            }
+        }
+    }
+
+    auto view = wrapRiveTexture(texToWrap, canvas->width(), canvas->height());
+    if (mirror != nullptr && view != nullptr)
+    {
+        static_cast<TextureViewGL*>(view.get())
+            ->retainCanvasMirror(std::move(mirror));
+    }
+    return view;
+}
+
 } // namespace rive::ore
diff --git a/renderer/src/ore/gl/ore_texture_gl.hpp b/renderer/src/ore/gl/ore_texture_gl.hpp
index b74dd45..e3100cb 100644
--- a/renderer/src/ore/gl/ore_texture_gl.hpp
+++ b/renderer/src/ore/gl/ore_texture_gl.hpp
@@ -1,5 +1,6 @@
 #pragma once
 #include "rive/renderer/ore/ore_texture.hpp"
+#include "rive/renderer/rive_render_image.hpp"
 
 namespace rive::ore
 {
@@ -28,8 +29,16 @@
     {}
     ~TextureViewGL() override;
 
+    // The canvas import mirror owns the GL texture this view borrows, so the
+    // view must keep it alive. Null for ordinary views.
+    void retainCanvasMirror(rcp<RenderImage> mirror)
+    {
+        m_retainedCanvasMirror = std::move(mirror);
+    }
+
 private:
     friend class ContextGL;
     unsigned int m_glTextureView = 0; // GLenum; 0 means use base texture
+    rcp<RenderImage> m_retainedCanvasMirror;
 };
 } // namespace rive::ore
diff --git a/renderer/src/ore/metal/ore_context_metal.mm b/renderer/src/ore/metal/ore_context_metal.mm
index 29642f5..0c305b3 100644
--- a/renderer/src/ore/metal/ore_context_metal.mm
+++ b/renderer/src/ore/metal/ore_context_metal.mm
@@ -12,6 +12,7 @@
 #include "ore_texture_metal.hpp"
 #include "rive/renderer/render_canvas.hpp"
 #include "rive/renderer/metal/render_context_metal_impl.h"
+#include "rive/renderer/ore/cmd/ore_replay.hpp"
 #include "rive/rive_types.hpp"
 
 #include <string>
@@ -1153,6 +1154,7 @@
     m_mtlCommandBuffer = [m_mtlQueue commandBuffer];
     // Serial of the command buffer about to be recorded.
     ++m_currentSerial;
+    m_pendingFrame.reset();
 }
 
 void ContextMetal::waitForGPU()
@@ -1167,6 +1169,14 @@
 {
     if (m_mtlCommandBuffer)
     {
+        // Drain the recorded frame before commit. Keyed on a non empty
+        // recording rather than the flag so a mid frame toggle still drains.
+        if (!m_pendingFrame.empty())
+        {
+            cmd::replayCommandBuffer(*this, m_pendingFrame);
+            m_pendingFrame.reset();
+        }
+
         // Capture deferred BindGroups in a `__block` vector that the
         // completion handler clears once the GPU is done with the
         // command buffer. Pre-fix the next `beginFrame()` cleared
diff --git a/renderer/src/ore/metal/ore_render_pass_metal.mm b/renderer/src/ore/metal/ore_render_pass_metal.mm
index eeec330..9db3da1 100644
--- a/renderer/src/ore/metal/ore_render_pass_metal.mm
+++ b/renderer/src/ore/metal/ore_render_pass_metal.mm
@@ -263,6 +263,10 @@
                            uint32_t firstInstance)
 {
     validate();
+    if (m_currentPipeline == nullptr)
+    {
+        return; // setPipeline was rejected (see lastError), drawing would crash
+    }
     [m_mtlEncoder drawPrimitives:m_mtlPrimitiveType
                      vertexStart:firstVertex
                      vertexCount:vertexCount
@@ -277,6 +281,10 @@
                                   uint32_t firstInstance)
 {
     validate();
+    if (m_currentPipeline == nullptr || m_mtlIndexBuffer == nil)
+    {
+        return; // rejected pipeline or missing index buffer, see lastError
+    }
     assert(m_mtlIndexBuffer != nil &&
            "Must call setIndexBuffer before drawIndexed");
 
diff --git a/renderer/src/render_context.cpp b/renderer/src/render_context.cpp
index 66cec3c..86efe5c 100644
--- a/renderer/src/render_context.cpp
+++ b/renderer/src/render_context.cpp
@@ -181,6 +181,12 @@
 {
     return m_impl->makeRenderCanvas(width, height);
 }
+
+rcp<RenderCanvas> RenderContext::makeDeferredRenderCanvas(uint32_t width,
+                                                          uint32_t height)
+{
+    return m_impl->makeDeferredRenderCanvas(width, height);
+}
 rive::ore::Context* RenderContext::ore()
 {
     if (m_oreContext == nullptr)
diff --git a/renderer/src/rive_render_path.cpp b/renderer/src/rive_render_path.cpp
index d9267f2..3a9e437 100644
--- a/renderer/src/rive_render_path.cpp
+++ b/renderer/src/rive_render_path.cpp
@@ -99,6 +99,7 @@
 void RiveRenderPath::addRenderPathBackwards(const RenderPath* path,
                                             const Mat2D& transform)
 {
+    assert(m_rawPathMutationLockCount == 0);
     auto riveRenderPath = static_cast<const RiveRenderPath*>(path);
     RawPath::Iter transformedPathIter =
         m_rawPath.addPathBackwards(riveRenderPath->m_rawPath, &transform);
@@ -112,7 +113,9 @@
 
 void RiveRenderPath::addRawPath(const RawPath& path)
 {
+    assert(m_rawPathMutationLockCount == 0);
     m_rawPath.addPath(path, nullptr);
+    m_dirt = kAllDirt;
 }
 
 const AABB& RiveRenderPath::getBounds() const
diff --git a/src/artboard.cpp b/src/artboard.cpp
index cfeb248..bf21b2b 100644
--- a/src/artboard.cpp
+++ b/src/artboard.cpp
@@ -261,6 +261,30 @@
     return true;
 }
 
+void Artboard::reinstanceNestedArtboards(Factory* factory)
+{
+    for (auto object : m_Objects)
+    {
+        if (object == nullptr || !object->is<NestedArtboard>())
+        {
+            continue;
+        }
+        auto nested = object->as<NestedArtboard>();
+        Artboard* current = nested->sourceArtboard();
+        if (current == nullptr || !current->isInstance() ||
+            current->m_artboardSource == nullptr)
+        {
+            continue;
+        }
+        auto replacement =
+            current->m_artboardSource->instance<ArtboardInstance>(factory);
+        if (replacement != nullptr)
+        {
+            nested->referencedArtboard(replacement.release());
+        }
+    }
+}
+
 StatusCode Artboard::initialize()
 {
     StatusCode code;
@@ -892,25 +916,6 @@
 
 void Artboard::pollAsyncWork() { rive_pollAsyncWork(); }
 
-void Artboard::drawCanvases()
-{
-#ifdef WITH_RIVE_SCRIPTING
-    if (m_scriptingVM)
-    {
-        auto* L = m_scriptingVM->state();
-        if (L != nullptr)
-        {
-            auto* context =
-                static_cast<ScriptingContext*>(lua_getthreaddata(L));
-            ScopedCanvasDrawingPhase phase(context);
-            internalDrawCanvases();
-            return;
-        }
-    }
-#endif
-    internalDrawCanvases();
-}
-
 void Artboard::advanceScriptedViewModels()
 {
 #ifdef WITH_RIVE_SCRIPTING
@@ -924,53 +929,6 @@
 #endif
 }
 
-void Artboard::internalDrawCanvases()
-{
-    for (auto obj : m_ScriptedObjects)
-    {
-        obj->scriptDrawCanvas();
-    }
-    for (auto artboardHost : m_ArtboardHosts)
-    {
-        for (int i = 0; i < artboardHost->artboardCount(); i++)
-        {
-            auto* nested = artboardHost->artboardInstance(i);
-            if (nested != nullptr)
-            {
-                nested->internalDrawCanvases();
-            }
-        }
-    }
-}
-
-#ifdef WITH_RIVE_SCRIPTING
-void* Artboard::findDrawCanvasLuauState() const
-{
-    for (auto* obj : m_ScriptedObjects)
-    {
-        if (obj->drawsCanvas())
-        {
-            return obj->state();
-        }
-    }
-    for (auto* host : m_ArtboardHosts)
-    {
-        for (int i = 0; i < host->artboardCount(); i++)
-        {
-            auto* nested = host->artboardInstance(i);
-            if (nested != nullptr)
-            {
-                if (auto* state = nested->findDrawCanvasLuauState())
-                {
-                    return state;
-                }
-            }
-        }
-    }
-    return nullptr;
-}
-#endif
-
 Core* Artboard::resolve(uint32_t id) const
 {
     if (id >= static_cast<int>(m_Objects.size()))
@@ -1620,7 +1578,6 @@
 void Artboard::draw(Renderer* renderer)
 {
     sm_frameId++;
-    drawCanvases();
     drawInternal(renderer);
 }
 
diff --git a/src/assets/script_asset.cpp b/src/assets/script_asset.cpp
index b1df3dc..2e92d41 100644
--- a/src/assets/script_asset.cpp
+++ b/src/assets/script_asset.cpp
@@ -106,7 +106,7 @@
     {
         generatorFunctionRef(ref);
         // Force re-verification on next init so that method detection (e.g.
-        // drawCanvas) reflects the newly compiled script.
+        // draw) reflects the newly compiled script.
         m_initted = false;
     }
 }
@@ -155,6 +155,21 @@
         // actually a function on the returned table).
         OptionalScriptedMethods::implementedMethods(
             serializedImplementedMethods() & methodMask);
+#ifdef WITH_RIVE_TOOLS
+        // Bit 15 was the removed drawCanvas callback: the editor detected it
+        // on this script, so its canvas work never runs until moved into
+        // draw. Legacy all-bits exports stay silent; the shipping runtime
+        // stays quiet entirely, the editor console carries the migration.
+        static const uint32_t kRetiredDrawCanvasBit = 1 << 15;
+        static const uint32_t kAllMethodsDefault = (1 << 21) - 1;
+        if (serializedImplementedMethods() != kAllMethodsDefault &&
+            (serializedImplementedMethods() & kRetiredDrawCanvasBit) != 0)
+        {
+            fprintf(stderr,
+                    "rive: script implements drawCanvas, which is no longer "
+                    "called; move its body into draw\n");
+        }
+#endif
         m_initted = true;
     }
     object->implementedMethods(implementedMethods());
diff --git a/src/file.cpp b/src/file.cpp
index 05af200..5ed3472 100644
--- a/src/file.cpp
+++ b/src/file.cpp
@@ -697,6 +697,7 @@
         ScriptingVM* vm = m_scriptingVM.get();
         if (vm != nullptr)
         {
+            routeScriptingToImportFactory(vm->context());
             // Set up the Data global (view model constructors) on the active
             // VM, whether it was created here or supplied externally (e.g. by
             // the CommandServer). Skip it when the VM's owner builds Data
@@ -736,6 +737,48 @@
     }
 }
 
+// Scripts reach the GPU through their ScriptingContext, and nothing else in
+// the import path hands them one, so a script that opened a gpuCanvas used to
+// fail on every runtime host. The factory the file imported through knows the
+// context it draws to and whether that work has to record, so route from it
+// here, ahead of performRegistration since registration can run script bodies.
+//
+// Only fills pointers the caller left null: the editor routes at workspace
+// level instead, because it swaps the whole ScriptingContext on every
+// recompile and per-VM routing would silently come undone. A caller that
+// already chose a context outranks the factory's default.
+void File::routeScriptingToImportFactory(ScriptingContext* context)
+{
+    if (context == nullptr || m_factory == nullptr)
+    {
+        return;
+    }
+    // Each pointer routes on its own. renderContext() and oreContext() read
+    // through factory fallbacks so they self-heal after a late device bind,
+    // but the canvas host has no fallback: bailing on a factory with no
+    // device yet would lose recording for good. Test the raw member, not
+    // renderContext() - the fallback would report the factory's own context
+    // and skip a caller that never chose one.
+    if (context->renderContextIsLateBound())
+    {
+        if (Factory* renderContext = m_factory->renderContext())
+        {
+            context->setRenderContext(renderContext);
+        }
+    }
+    if (context->deferredCanvasHost() == nullptr)
+    {
+        if (cmd::DeferredCanvasHost* host = m_factory->deferredCanvasHost())
+        {
+            // Recording: script canvas frames become commands, and the ore
+            // context has to be the session's recorder rather than the render
+            // context's real one that setRenderContext would otherwise imply.
+            context->setDeferredCanvasHost(host);
+            context->setOreContext(m_factory->ore());
+        }
+    }
+}
+
 void File::makeScriptingVM()
 {
     cleanupScriptingVM();
diff --git a/src/lua/lua_artboards.cpp b/src/lua/lua_artboards.cpp
index 68939f1..3e7ac2e 100644
--- a/src/lua/lua_artboards.cpp
+++ b/src/lua/lua_artboards.cpp
@@ -101,14 +101,6 @@
     return 0;
 }
 
-static int artboard_draw_canvas(lua_State* L)
-{
-    auto scriptedArtboard = lua_torive<ScriptedArtboard>(L, 1);
-    scriptedArtboard->artboard()->internalDrawCanvases();
-
-    return 0;
-}
-
 bool ScriptedArtboard::advance(float seconds)
 {
     auto machine = stateMachine();
@@ -226,8 +218,6 @@
         {
             case (int)LuaAtoms::draw:
                 return artboard_draw(L);
-            case (int)LuaAtoms::drawCanvas:
-                return artboard_draw_canvas(L);
             case (int)LuaAtoms::advance:
                 return artboard_advance(L);
             case (int)LuaAtoms::instance:
diff --git a/src/lua/lua_scripted_context.cpp b/src/lua/lua_scripted_context.cpp
index a62ed9e..d9a4389 100644
--- a/src/lua/lua_scripted_context.cpp
+++ b/src/lua/lua_scripted_context.cpp
@@ -27,11 +27,27 @@
 
 // Pushes a GPU features table onto the Lua stack. Queries the ORE context
 // when available, otherwise returns conservative defaults. Always returns 1.
+//
+// Errors instead of answering when the context is recording and does not yet
+// know its replay device. Conservative defaults would be the wrong answer to
+// give: they are indistinguishable from a real low end device, so a script
+// cannot tell it is being guessed at, and the branch it picks is written into
+// a stream that replays flawlessly on hardware that contradicts it. Failing at
+// the read is the only signal that fits through this API.
 int lua_push_gpu_features(lua_State* L)
 {
 #if defined(RIVE_CANVAS) && defined(RIVE_ORE)
     auto* oreCtx = static_cast<ore::Context*>(
         static_cast<ScriptingContext*>(lua_getthreaddata(L))->oreContext());
+    if (oreCtx != nullptr && !oreCtx->featuresKnown())
+    {
+        luaL_error(L,
+                   "context.features is not available yet: this script is "
+                   "recording for a GPU device that has not been attached, so "
+                   "no capability can be reported without guessing at it. "
+                   "Read features from a method that runs after the first "
+                   "frame instead of at module scope");
+    }
     if (oreCtx != nullptr)
     {
         const auto& f = oreCtx->features();
@@ -376,24 +392,39 @@
                     static_cast<ScriptingContext*>(lua_getthreaddata(L));
                 auto* renderCtx = static_cast<gpu::RenderContext*>(
                     scriptingCtx->renderContext());
+                auto* handle = lua_newrive<ScriptedCanvas>(L);
+                handle->m_L = L;
+                handle->renderCtx = renderCtx;
+
+                // A size-less canvas allocates nothing, so it needs no device.
+                // Checked before the context, or a layout script that does not
+                // know its size at init is refused for a device it will only
+                // need at resize().
+                if (cw == 0 || ch == 0)
+                {
+                    return 1;
+                }
                 if (renderCtx == nullptr)
                 {
+                    // A recording session binds its device after import, and
+                    // generators size their canvas at construction, before any
+                    // texture exists. Record the request; satisfyPending
+                    // materializes it on first use once the device arrives.
+                    if (scriptingCtx->deferredCanvasHost() != nullptr)
+                    {
+                        handle->pendingWidth = cw;
+                        handle->pendingHeight = ch;
+                        return 1;
+                    }
                     luaL_error(
                         L,
                         "context:canvas() requires a RenderContext — call "
                         "setRenderContext() first");
                     return 0;
                 }
-                auto* handle = lua_newrive<ScriptedCanvas>(L);
-                handle->m_L = L;
-                handle->renderCtx = renderCtx;
 
-                if (cw == 0 || ch == 0)
-                {
-                    return 1;
-                }
-
-                auto canvas = renderCtx->makeRenderCanvas(cw, ch);
+                auto canvas =
+                    allocScriptRenderCanvas(renderCtx, scriptingCtx, cw, ch);
                 if (!canvas)
                 {
                     luaL_error(
@@ -456,8 +487,27 @@
                 }
                 auto* gpuRenderCtx = static_cast<gpu::RenderContext*>(
                     gpuScriptingCtx->renderContext());
+                auto* handle = lua_newrive<ScriptedGPUCanvas>(L);
+                handle->m_L = L;
+                handle->renderCtx = gpuRenderCtx;
+
+                // The documented size-less contract: no descriptor means no
+                // backing texture, so nothing here touches a device. Checked
+                // ahead of the contexts, or a layout script that learns its
+                // size at resize() is refused for a device it does not use.
+                if (gw == 0 || gh == 0)
+                {
+                    return 1;
+                }
                 if (gpuRenderCtx == nullptr)
                 {
+                    // Same late-device contract as canvas() above.
+                    if (gpuScriptingCtx->deferredCanvasHost() != nullptr)
+                    {
+                        handle->pendingWidth = gw;
+                        handle->pendingHeight = gh;
+                        return 1;
+                    }
                     luaL_error(
                         L,
                         "context:gpuCanvas() requires a RenderContext — call "
@@ -474,16 +524,11 @@
                         "scriptingWorkspaceSetOreContext() before requestVM()");
                     return 0;
                 }
-                auto* handle = lua_newrive<ScriptedGPUCanvas>(L);
-                handle->m_L = L;
-                handle->renderCtx = gpuRenderCtx;
 
-                if (gw == 0 || gh == 0)
-                {
-                    return 1;
-                }
-
-                auto canvas = gpuRenderCtx->makeRenderCanvas(gw, gh);
+                auto canvas = allocScriptRenderCanvas(gpuRenderCtx,
+                                                      gpuScriptingCtx,
+                                                      gw,
+                                                      gh);
                 if (!canvas)
                 {
                     luaL_error(
diff --git a/src/lua/renderer/lua_gpu.cpp b/src/lua/renderer/lua_gpu.cpp
index 66121df..c4be279 100644
--- a/src/lua/renderer/lua_gpu.cpp
+++ b/src/lua/renderer/lua_gpu.cpp
@@ -6,7 +6,10 @@
 #include "rive/renderer/ore/ore_context.hpp"
 #include "rive/renderer/ore/ore_rstb_entry_container.hpp"
 #include "rive/renderer/ore/ore_render_pass.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_render_pass.hpp"
 #include "rive/renderer/ore/ore_shader_module.hpp"
+#include "rive/renderer/cmd/deferred_canvas_host.hpp"
+#include "rive/renderer/cmd/deferred_render_resource.hpp"
 #include "rive/renderer/render_canvas.hpp"
 #include "rive/renderer/render_context.hpp"
 #include "rive/renderer/render_context_impl.hpp"
@@ -18,6 +21,7 @@
 #include "rive/shapes/paint/color.hpp"
 
 #include <algorithm>
+#include <cassert>
 #include <cstring>
 #include <stdio.h>
 #include <string>
@@ -505,6 +509,18 @@
         static_cast<ScriptingContext*>(lua_getthreaddata(L))->oreContext());
 }
 
+/// Whether a capability gate below can be decided at all. A recording context
+/// with no replay device attached yet holds Features' own initializers, which
+/// deny nearly everything; gating on those would reject an operation the
+/// replay device very likely supports. These gates are diagnostics — the real
+/// backend is the authority — so an undecidable one lets the call through
+/// rather than inventing a refusal, which is the same fiction as inventing a
+/// capability, only in the direction that breaks working content.
+static bool features_are_known(Context* oreCtx)
+{
+    return oreCtx != nullptr && oreCtx->featuresKnown();
+}
+
 /// RSTB ShaderTarget the active ore backend consumes.
 static ShaderTarget currentShaderTarget(Context* oreCtx)
 {
@@ -611,6 +627,7 @@
                 desc.glFixupBytes = fx.empty() ? nullptr : fx.data();
                 desc.glFixupSize = static_cast<uint32_t>(fx.size());
             }
+
             auto mod = oreCtx->makeShaderModule(desc);
             if (!mod)
                 return false;
@@ -993,7 +1010,7 @@
                    "sampleCount must be a power of two (got %u)",
                    sampleCount);
     auto* ctx = getOreContext(L);
-    if (ctx)
+    if (features_are_known(ctx))
     {
         uint32_t maxSamples = ctx->features().maxSamples;
         if (sampleCount > maxSamples)
@@ -1048,7 +1065,7 @@
     // Gate float render targets: without the matching capability they make an
     // incomplete FBO that renders black. Sampled-only float textures are fine.
     // 16-bit floats need half-float, 32-bit and packed need full float.
-    if (desc.renderTarget)
+    if (desc.renderTarget && features_are_known(ctx))
     {
         FloatColorClass fc = floatColorClass(desc.format);
         const Features& feat = ctx->features();
@@ -1424,7 +1441,8 @@
                        "two in [1, 16] (got %u)",
                        a);
         }
-        if (a > 1 && !getOreContext(L)->features().anisotropicFiltering)
+        if (a > 1 && features_are_known(getOreContext(L)) &&
+            !getOreContext(L)->features().anisotropicFiltering)
         {
             luaL_error(L,
                        "GPUSampler.new: maxAnisotropy=%u requires "
@@ -2541,7 +2559,8 @@
         lua_isnumber(L, 4) ? static_cast<uint32_t>(lua_tonumber(L, 4)) : 0;
     uint32_t firstInstance =
         lua_isnumber(L, 5) ? static_cast<uint32_t>(lua_tonumber(L, 5)) : 0;
-    if (firstInstance > 0 && !getOreContext(L)->features().drawBaseInstance)
+    if (firstInstance > 0 && features_are_known(getOreContext(L)) &&
+        !getOreContext(L)->features().drawBaseInstance)
     {
         luaL_error(L,
                    "draw: firstInstance=%u requires the drawBaseInstance "
@@ -2569,7 +2588,8 @@
         lua_isnumber(L, 5) ? static_cast<int32_t>(lua_tointeger(L, 5)) : 0;
     uint32_t firstInstance =
         lua_isnumber(L, 6) ? static_cast<uint32_t>(lua_tonumber(L, 6)) : 0;
-    if (baseVertex != 0 && !getOreContext(L)->features().drawBaseInstance)
+    if (baseVertex != 0 && features_are_known(getOreContext(L)) &&
+        !getOreContext(L)->features().drawBaseInstance)
     {
         luaL_error(L,
                    "drawIndexed: baseVertex=%d requires the "
@@ -2577,7 +2597,8 @@
                    "does not support",
                    baseVertex);
     }
-    if (firstInstance > 0 && !getOreContext(L)->features().drawBaseInstance)
+    if (firstInstance > 0 && features_are_known(getOreContext(L)) &&
+        !getOreContext(L)->features().drawBaseInstance)
     {
         luaL_error(L,
                    "drawIndexed: firstInstance=%u requires the "
@@ -2726,10 +2747,13 @@
 
     auto* scriptingContext =
         static_cast<ScriptingContext*>(lua_getthreaddata(L));
-    if (scriptingContext == nullptr || !scriptingContext->canvasDrawingPhase())
+    // Recording brackets the pass; an immediate context cannot nest a pass
+    // inside the open screen frame.
+    if (scriptingContext == nullptr || !oreCtx->isRecording())
     {
         luaL_error(L,
-                   "GPUCanvas:beginRenderPass() called outside drawing phase");
+                   "GPUCanvas:beginRenderPass() requires the deferred "
+                   "recorder");
     }
 
     luaL_checktype(L, 2, LUA_TTABLE);
@@ -2986,7 +3010,8 @@
     }
 
     auto* rp = lua_newrive<ScriptedGPURenderPass>(L);
-    rp->pass = oreCtx->beginRenderPass(passDesc);
+    // Records in deferred mode, returns the live backend pass in immediate.
+    rp->pass = ore::cmd::beginRenderPassRecordingOrImmediate(*oreCtx, passDesc);
     rp->m_context = oreCtx;
     rp->m_finished = false;
     rp->sampleCount =
@@ -2997,42 +3022,60 @@
     return 1;
 }
 
-// Recreate the underlying RenderCanvas at a new size, then re-wrap its backing
-// texture for use in ORE render passes.  The handle's `.image` ref continues to
-// point to the updated canvas image. Resizing to zero in either dimension
-// drops the backing texture and leaves the canvas in a deferred state.
-static int gpucanvashandle_resize(lua_State* L)
+rcp<gpu::RenderCanvas> rive::allocScriptRenderCanvas(gpu::RenderContext* rc,
+                                                     ScriptingContext* ctx,
+                                                     uint32_t width,
+                                                     uint32_t height)
 {
-    auto* self = lua_torive<ScriptedGPUCanvas>(L, 1);
-    uint32_t w = static_cast<uint32_t>(luaL_checkunsigned(L, 2));
-    uint32_t h = static_cast<uint32_t>(luaL_checkunsigned(L, 3));
-
-    if (self->renderCtx == nullptr)
+    assert(rc != nullptr);
+    assert(ctx != nullptr);
+    if (ctx->deferredCanvasHost() != nullptr || ctx->renderContextIsLateBound())
     {
-        luaL_error(L, "GPUCanvas: renderCtx not initialized");
+        return rc->makeDeferredRenderCanvas(width, height);
+    }
+    return rc->makeRenderCanvas(width, height);
+}
+
+// The device a canvas should allocate against right now, which is not
+// necessarily the one that existed when the handle was made: web builds one per
+// render texture and attaches it after the file has imported.
+static gpu::RenderContext* liveRenderContext(ScriptingContext* scriptingCtx)
+{
+    if (scriptingCtx == nullptr)
+    {
+        return nullptr;
+    }
+    return static_cast<gpu::RenderContext*>(scriptingCtx->renderContext());
+}
+
+// Allocates the backing for a pending size, if there is one and a device has
+// turned up to allocate it against. Errors only on a device that is present and
+// refuses; a device that has not arrived yet leaves the request pending, which
+// is what makes a size-less canvas usable on a host that attaches late.
+static void gpucanvas_satisfyPending(lua_State* L, ScriptedGPUCanvas* self)
+{
+    if (self->pendingWidth == 0 || self->pendingHeight == 0)
+    {
+        return;
+    }
+    auto* scriptingCtx = static_cast<ScriptingContext*>(lua_getthreaddata(L));
+    auto* renderCtx = liveRenderContext(scriptingCtx);
+    if (renderCtx == nullptr)
+    {
+        return;
     }
     auto* oreCtx = getOreContext(L);
     if (oreCtx == nullptr)
     {
-        luaL_error(L, "GPUCanvas: GPU context not initialized");
+        return;
     }
-
-    if (w == 0 || h == 0)
-    {
-        if (self->m_L != nullptr && self->m_imageRef != LUA_NOREF)
-        {
-            lua_unref(self->m_L, self->m_imageRef);
-            self->m_imageRef = LUA_NOREF;
-        }
-        self->canvas = nullptr;
-        self->oreColorView = nullptr;
-        return 0;
-    }
+    self->renderCtx = renderCtx;
+    uint32_t w = self->pendingWidth, h = self->pendingHeight;
 
     // Allocate and wrap the new backing BEFORE touching the existing
     // canvas/view/imageRef. If either step throws (via luaL_error), the
     // canvas keeps its previous, still-valid backing.
-    auto newCanvas = self->renderCtx->makeRenderCanvas(w, h);
+    auto newCanvas = allocScriptRenderCanvas(renderCtx, scriptingCtx, w, h);
     if (!newCanvas)
     {
         luaL_error(L, "GPUCanvas:resize() failed to create RenderCanvas");
@@ -3050,19 +3093,62 @@
     }
     self->canvas = std::move(newCanvas);
     self->oreColorView = std::move(newColorView);
+    self->pendingWidth = 0;
+    self->pendingHeight = 0;
 
     auto* img = lua_newrive<ScriptedImage>(L);
     img->image =
         ref_rcp(static_cast<RenderImage*>(self->canvas->renderImage()));
     self->m_imageRef = lua_ref(L, -1);
     lua_pop(L, 1); // pop image
+}
 
+// Recreate the underlying RenderCanvas at a new size, then re-wrap its backing
+// texture for use in ORE render passes.  The handle's `.image` ref continues to
+// point to the updated canvas image. Resizing to zero in either dimension
+// drops the backing texture and leaves the canvas in a deferred state.
+static int gpucanvashandle_resize(lua_State* L)
+{
+    auto* self = lua_torive<ScriptedGPUCanvas>(L, 1);
+    uint32_t w = static_cast<uint32_t>(luaL_checkunsigned(L, 2));
+    uint32_t h = static_cast<uint32_t>(luaL_checkunsigned(L, 3));
+
+    if (w == 0 || h == 0)
+    {
+        if (self->m_L != nullptr && self->m_imageRef != LUA_NOREF)
+        {
+            lua_unref(self->m_L, self->m_imageRef);
+            self->m_imageRef = LUA_NOREF;
+        }
+        self->canvas = nullptr;
+        self->oreColorView = nullptr;
+        self->pendingWidth = 0;
+        self->pendingHeight = 0;
+        return 0;
+    }
+
+    // Generators call resize() every frame, so recreating on an unchanged
+    // size would churn a new texture per frame and stall the render thread.
+    if (self->canvas != nullptr && self->canvas->width() == w &&
+        self->canvas->height() == h)
+    {
+        return 0;
+    }
+
+    // A generator resizes once, when layout hands it the real size. On web that
+    // still precedes the render texture's attach, so the request is recorded
+    // and satisfied on first use rather than refused for a device the contract
+    // did not require at init either.
+    self->pendingWidth = w;
+    self->pendingHeight = h;
+    gpucanvas_satisfyPending(L, self);
     return 0;
 }
 
 static int gpucanvashandle_colorview(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPUCanvas>(L, 1);
+    gpucanvas_satisfyPending(L, self);
     if (!self->oreColorView)
     {
         luaL_error(L,
@@ -3074,11 +3160,15 @@
     return 1;
 }
 
+// A canvas has a size from the moment resize() is called; only the texture may
+// still be pending. Reporting zero here instead would break the generator that
+// sizes its depth and MSAA attachments off canvas.width the same frame.
 static void gpucanvashandle_direct_width(void* udata, void* result)
 {
     auto* self = (ScriptedGPUCanvas*)udata;
     lua_userdatadirectfield_setnumber(result,
-                                      self->canvas ? self->canvas->width() : 0);
+                                      self->canvas ? self->canvas->width()
+                                                   : self->pendingWidth);
 }
 
 static void gpucanvashandle_direct_height(void* udata, void* result)
@@ -3086,7 +3176,7 @@
     auto* self = (ScriptedGPUCanvas*)udata;
     lua_userdatadirectfield_setnumber(result,
                                       self->canvas ? self->canvas->height()
-                                                   : 0);
+                                                   : self->pendingHeight);
 }
 
 static int gpucanvashandle_index(lua_State* L)
@@ -3098,6 +3188,9 @@
         luaL_typeerrorL(L, 2, lua_typename(L, LUA_TSTRING));
     }
     auto* self = lua_torive<ScriptedGPUCanvas>(L, 1);
+    // Every field below reads the backing, and a generator reads them each
+    // frame, so this is where a size pending on a late device is honoured.
+    gpucanvas_satisfyPending(L, self);
     switch (atom)
     {
         case (int)LuaAtoms::image:
@@ -3109,10 +3202,14 @@
             lua_pushnil(L);
             return 1;
         case (int)LuaAtoms::width:
-            lua_pushnumber(L, self->canvas ? self->canvas->width() : 0);
+            lua_pushnumber(L,
+                           self->canvas ? self->canvas->width()
+                                        : self->pendingWidth);
             return 1;
         case (int)LuaAtoms::height:
-            lua_pushnumber(L, self->canvas ? self->canvas->height() : 0);
+            lua_pushnumber(L,
+                           self->canvas ? self->canvas->height()
+                                        : self->pendingHeight);
             return 1;
         case (int)LuaAtoms::format:
             // Realized canvas reports its texture format. Deferred canvas
@@ -3160,6 +3257,51 @@
 // Canvas (2D Rive renderer canvas)
 // ============================================================================
 
+// The 2D counterpart of gpucanvas_satisfyPending, for the same reason: a
+// size-less canvas is legal, and on web the device shows up after layout has
+// already handed the generator its real size.
+static void canvas_satisfyPending(lua_State* L, ScriptedCanvas* self)
+{
+    if (self->pendingWidth == 0 || self->pendingHeight == 0)
+    {
+        return;
+    }
+    auto* scriptingCtx = static_cast<ScriptingContext*>(lua_getthreaddata(L));
+    auto* renderCtx = liveRenderContext(scriptingCtx);
+    if (renderCtx == nullptr)
+    {
+        return;
+    }
+    self->renderCtx = renderCtx;
+
+    // Allocate the new backing BEFORE touching the existing canvas/imageRef.
+    // If allocation throws (via luaL_error), the canvas keeps its previous,
+    // still-valid backing.
+    auto newCanvas = allocScriptRenderCanvas(renderCtx,
+                                             scriptingCtx,
+                                             self->pendingWidth,
+                                             self->pendingHeight);
+    if (!newCanvas)
+    {
+        luaL_error(L, "Canvas:resize() failed to create RenderCanvas");
+    }
+
+    if (self->m_L != nullptr && self->m_imageRef != LUA_NOREF)
+    {
+        lua_unref(self->m_L, self->m_imageRef);
+        self->m_imageRef = LUA_NOREF;
+    }
+    self->canvas = std::move(newCanvas);
+    self->pendingWidth = 0;
+    self->pendingHeight = 0;
+
+    auto* img = lua_newrive<ScriptedImage>(L);
+    img->image =
+        ref_rcp(static_cast<RenderImage*>(self->canvas->renderImage()));
+    self->m_imageRef = lua_ref(L, -1);
+    lua_pop(L, 1);
+}
+
 // Recreate the underlying RenderCanvas at a new size. Must not be called
 // between beginFrame() and endFrame(). Resizing to zero in either dimension
 // drops the backing texture and leaves the canvas in a deferred state.
@@ -3169,10 +3311,6 @@
     uint32_t w = static_cast<uint32_t>(luaL_checkunsigned(L, 2));
     uint32_t h = static_cast<uint32_t>(luaL_checkunsigned(L, 3));
 
-    if (self->renderCtx == nullptr)
-    {
-        luaL_error(L, "Canvas: renderCtx not initialized");
-    }
     if (self->m_state != CanvasState::Idle)
     {
         luaL_error(L, "Canvas:resize() called during an active frame");
@@ -3186,31 +3324,21 @@
             self->m_imageRef = LUA_NOREF;
         }
         self->canvas = nullptr;
+        self->pendingWidth = 0;
+        self->pendingHeight = 0;
         return 0;
     }
 
-    // Allocate the new backing BEFORE touching the existing canvas/imageRef.
-    // If makeRenderCanvas throws (via luaL_error), the canvas keeps its
-    // previous, still-valid backing.
-    auto newCanvas = self->renderCtx->makeRenderCanvas(w, h);
-    if (!newCanvas)
+    // Resizing to an unchanged size would churn a new texture per frame.
+    if (self->canvas != nullptr && self->canvas->width() == w &&
+        self->canvas->height() == h)
     {
-        luaL_error(L, "Canvas:resize() failed to create RenderCanvas");
+        return 0;
     }
 
-    if (self->m_L != nullptr && self->m_imageRef != LUA_NOREF)
-    {
-        lua_unref(self->m_L, self->m_imageRef);
-        self->m_imageRef = LUA_NOREF;
-    }
-    self->canvas = std::move(newCanvas);
-
-    auto* img = lua_newrive<ScriptedImage>(L);
-    img->image =
-        ref_rcp(static_cast<RenderImage*>(self->canvas->renderImage()));
-    self->m_imageRef = lua_ref(L, -1);
-    lua_pop(L, 1);
-
+    self->pendingWidth = w;
+    self->pendingHeight = h;
+    canvas_satisfyPending(L, self);
     return 0;
 }
 
@@ -3223,15 +3351,20 @@
 static int canvashandle_beginframe(lua_State* L)
 {
     auto* self = lua_torive<ScriptedCanvas>(L, 1);
+    canvas_satisfyPending(L, self);
     if (self->renderCtx == nullptr)
     {
         luaL_error(L, "Canvas: renderCtx not initialized");
     }
     auto* scriptingContext =
         static_cast<ScriptingContext*>(lua_getthreaddata(L));
-    if (scriptingContext == nullptr || !scriptingContext->canvasDrawingPhase())
+    // Recording brackets the content instead of opening a real frame; an
+    // immediate context cannot nest one inside the open screen frame.
+    Context* recordingOre = getOreContext(L);
+    if (scriptingContext == nullptr || recordingOre == nullptr ||
+        !recordingOre->isRecording())
     {
-        luaL_error(L, "Canvas:beginFrame() called outside drawing phase");
+        luaL_error(L, "Canvas:beginFrame() requires the deferred recorder");
     }
     if (self->m_state != CanvasState::Idle)
     {
@@ -3261,16 +3394,35 @@
         lua_pop(L, 1);
     }
 
-    self->renderCtx->beginFrame(desc);
-
-    // Allocate a RiveRenderer that issues into this render context.
-    // Deleted in endFrame() (or in the destructor if endFrame is never called).
-    self->m_riveRenderer = new RiveRenderer(self->renderCtx);
+    // A deferred host hands back a recorder instead of opening a real
+    // RenderContext frame, the real canvas frame opens at replay.
+    Renderer* renderer = nullptr;
+    if (auto* host = scriptingContext->deferredCanvasHost())
+    {
+        self->m_deferredHost = host;
+        renderer =
+            host->beginCanvasContent(self->canvas.get(), desc.clearColor);
+    }
+    else
+    {
+        self->renderCtx->beginFrame(desc);
+        // Allocate a RiveRenderer that issues into this render context. Deleted
+        // in endFrame() (or in the destructor if endFrame is never called).
+        self->m_riveRenderer = new RiveRenderer(self->renderCtx);
+        renderer = self->m_riveRenderer;
+    }
     self->m_state = CanvasState::Rendering;
 
-    // Push a non-owning ScriptedRenderer wrapping our RiveRenderer and keep a
+    // Track the open frame on the context so the post-error cleanup can close
+    // it if the script never reaches endFrame. The ref also pins the canvas.
+    lua_pushvalue(L, 1);
+    self->m_openFrameRef = lua_ref(L, -1);
+    lua_pop(L, 1);
+    scriptingContext->registerOpenCanvasFrame(self->m_openFrameRef);
+
+    // Push a non-owning ScriptedRenderer wrapping our renderer and keep a
     // registry ref so the Lua object stays alive until endFrame().
-    lua_newrive<ScriptedRenderer>(L, self->m_riveRenderer);
+    lua_newrive<ScriptedRenderer>(L, renderer);
     lua_pushvalue(L, -1);
     self->m_rendererRef = lua_ref(L, -1);
     lua_pop(L, 1); // pop the extra copy used for ref; original stays on stack
@@ -3278,14 +3430,18 @@
     return 1; // returns the ScriptedRenderer
 }
 
-// Flush all pending Rive draw calls for this frame to the canvas render target,
-// then release the renderer.  Must be called after beginFrame().
-static int canvashandle_endframe(lua_State* L)
+// The body of Canvas:endFrame, shared with the post-error orphan cleanup.
+static void canvasEndFrameImpl(lua_State* L, ScriptedCanvas* self)
 {
-    auto* self = lua_torive<ScriptedCanvas>(L, 1);
-    if (self->m_state != CanvasState::Rendering)
+    if (self->m_openFrameRef != LUA_NOREF)
     {
-        luaL_error(L, "Canvas:endFrame() called without beginFrame()");
+        auto* context = static_cast<ScriptingContext*>(lua_getthreaddata(L));
+        if (context != nullptr)
+        {
+            context->unregisterOpenCanvasFrame(self->m_openFrameRef);
+        }
+        lua_unref(L, self->m_openFrameRef);
+        self->m_openFrameRef = LUA_NOREF;
     }
 
     // Null out the ScriptedRenderer's pointer so it can no longer issue draws.
@@ -3303,6 +3459,15 @@
         self->m_rendererRef = LUA_NOREF;
     }
 
+    // Close the content bracket, the real canvas frame flushes at replay.
+    if (self->m_deferredHost != nullptr)
+    {
+        self->m_deferredHost->endCanvasContent(self->canvas.get());
+        self->m_deferredHost = nullptr;
+        self->m_state = CanvasState::Idle;
+        return;
+    }
+
     // Create a command buffer, flush the render context into the canvas
     // render target, then commit. Without a proper command buffer the
     // buffer ring mutex would never be unlocked (the completion handler
@@ -3320,7 +3485,18 @@
     delete self->m_riveRenderer;
     self->m_riveRenderer = nullptr;
     self->m_state = CanvasState::Idle;
+}
 
+// Flush all pending Rive draw calls for this frame to the canvas render target,
+// then release the renderer.  Must be called after beginFrame().
+static int canvashandle_endframe(lua_State* L)
+{
+    auto* self = lua_torive<ScriptedCanvas>(L, 1);
+    if (self->m_state != CanvasState::Rendering)
+    {
+        luaL_error(L, "Canvas:endFrame() called without beginFrame()");
+    }
+    canvasEndFrameImpl(L, self);
     return 0;
 }
 
@@ -3328,7 +3504,8 @@
 {
     auto* self = (ScriptedCanvas*)udata;
     lua_userdatadirectfield_setnumber(result,
-                                      self->canvas ? self->canvas->width() : 0);
+                                      self->canvas ? self->canvas->width()
+                                                   : self->pendingWidth);
 }
 
 static void canvashandle_direct_height(void* udata, void* result)
@@ -3336,7 +3513,7 @@
     auto* self = (ScriptedCanvas*)udata;
     lua_userdatadirectfield_setnumber(result,
                                       self->canvas ? self->canvas->height()
-                                                   : 0);
+                                                   : self->pendingHeight);
 }
 
 static int canvashandle_index(lua_State* L)
@@ -3348,6 +3525,7 @@
         luaL_typeerrorL(L, 2, lua_typename(L, LUA_TSTRING));
     }
     auto* self = lua_torive<ScriptedCanvas>(L, 1);
+    canvas_satisfyPending(L, self);
     switch (atom)
     {
         case (int)LuaAtoms::image:
@@ -3359,10 +3537,14 @@
             lua_pushnil(L);
             return 1;
         case (int)LuaAtoms::width:
-            lua_pushnumber(L, self->canvas ? self->canvas->width() : 0);
+            lua_pushnumber(L,
+                           self->canvas ? self->canvas->width()
+                                        : self->pendingWidth);
             return 1;
         case (int)LuaAtoms::height:
-            lua_pushnumber(L, self->canvas ? self->canvas->height() : 0);
+            lua_pushnumber(L,
+                           self->canvas ? self->canvas->height()
+                                        : self->pendingHeight);
             return 1;
     }
     luaL_error(L, "'%s' is not a valid index of Canvas", key);
@@ -3629,20 +3811,6 @@
         return 0;
     }
 
-    // Safe cast — returns nullptr if the image isn't GPU-backed.
-    auto* riveImage = lite_rtti_cast<RiveRenderImage*>(self->image.get());
-    if (!riveImage)
-    {
-        luaL_error(L, "Image is not a GPU-backed RiveRenderImage");
-        return 0;
-    }
-    gpu::Texture* sourceGpuTex = riveImage->getTexture();
-    if (!sourceGpuTex)
-    {
-        luaL_error(L, "Image GPU texture not available");
-        return 0;
-    }
-
     // Get ore::Context from scripting context.
     auto* ctx = static_cast<ScriptingContext*>(lua_getthreaddata(L));
     auto* oreCtx = static_cast<ore::Context*>(ctx->oreContext());
@@ -3652,8 +3820,47 @@
         return 0;
     }
 
-    if (!self->cachedOreView)
+    if (!self->cachedOreView && oreCtx->isRecording())
     {
+        // Image:view() must not touch the driver while recording, so record
+        // by resource id and let the consumer wrap at replay.
+        if (auto* deferredImage =
+                lite_rtti_cast<rive::cmd::DeferredRenderImage*>(
+                    self->image.get()))
+        {
+            self->cachedOreView =
+                oreCtx->recordWrapImageView(deferredImage->id(),
+                                            self->image->width(),
+                                            self->image->height());
+        }
+        else
+        {
+            self->cachedOreView =
+                oreCtx->recordWrapCanvasImage(self->image.get(),
+                                              self->image->width(),
+                                              self->image->height());
+        }
+        if (!self->cachedOreView)
+        {
+            luaL_error(L, "Image:view() recording failed");
+            return 0;
+        }
+    }
+    else if (!self->cachedOreView)
+    {
+        // Immediate mode requires a live GPU backed image.
+        auto* riveImage = lite_rtti_cast<RiveRenderImage*>(self->image.get());
+        if (!riveImage)
+        {
+            luaL_error(L, "Image is not a GPU-backed RiveRenderImage");
+            return 0;
+        }
+        gpu::Texture* sourceGpuTex = riveImage->getTexture();
+        if (!sourceGpuTex)
+        {
+            luaL_error(L, "Image GPU texture not available");
+            return 0;
+        }
         // GL canvas-import boundary: on GL/WebGL, sampling a Rive 2D
         // RenderCanvas as a WGSL texture requires a Y-flipped companion
         // because PLS renders the canvas bottom-up while WGSL expects
@@ -3728,6 +3935,39 @@
     context->printError(L);
     lua_pop(L, 1);
 }
+
+void rive_lua_closeOrphanCanvasFrames(lua_State* L)
+{
+    auto* context = static_cast<ScriptingContext*>(lua_getthreaddata(L));
+    if (context == nullptr)
+    {
+        return;
+    }
+    auto refs = context->takeOpenCanvasFrames();
+    if (refs.empty())
+    {
+        return;
+    }
+    for (int ref : refs)
+    {
+        rive_lua_pushRef(L, ref);
+        if (!lua_isnil(L, -1))
+        {
+            auto* canvas = lua_torive<ScriptedCanvas>(L, -1);
+            if (canvas != nullptr && canvas->m_state == CanvasState::Rendering)
+            {
+                // Also releases the refs, including this one.
+                canvasEndFrameImpl(L, canvas);
+            }
+        }
+        lua_pop(L, 1);
+    }
+    lua_pushstring(L,
+                   "Canvas frame left open at script return. "
+                   "Call canvas:endFrame() before returning.");
+    context->printError(L);
+    lua_pop(L, 1);
+}
 } // namespace rive
 
 #endif // RIVE_CANVAS && RIVE_ORE
diff --git a/src/lua/rive_lua_libs.cpp b/src/lua/rive_lua_libs.cpp
index 3ac39e6..41ac42e 100644
--- a/src/lua/rive_lua_libs.cpp
+++ b/src/lua/rive_lua_libs.cpp
@@ -293,7 +293,6 @@
     {"canvas", (int16_t)LuaAtoms::canvas},
     {"gpuCanvas", (int16_t)LuaAtoms::gpuCanvas},
     {"features", (int16_t)LuaAtoms::features},
-    {"drawCanvas", (int16_t)LuaAtoms::drawCanvas},
     {"shader", (int16_t)LuaAtoms::shader},
     {"format", (int16_t)LuaAtoms::format},
     {"andThen", (int16_t)LuaAtoms::andThen},
@@ -453,6 +452,7 @@
     int ret = context->pCall(state, nargs, nresults);
 #ifdef RIVE_ORE
     rive_lua_closeOrphanRenderPass(state);
+    rive_lua_closeOrphanCanvasFrames(state);
 #endif
     return ret;
 }
@@ -468,6 +468,7 @@
     int ret = context->pCall(state, nargs, nresults);
 #ifdef RIVE_ORE
     rive_lua_closeOrphanRenderPass(state);
+    rive_lua_closeOrphanCanvasFrames(state);
 #endif
     return ret;
 }
diff --git a/src/nested_artboard.cpp b/src/nested_artboard.cpp
index 15fcbe6..b72b945 100644
--- a/src/nested_artboard.cpp
+++ b/src/nested_artboard.cpp
@@ -280,7 +280,11 @@
 
     if (artboard != nullptr)
     {
-        auto artboardInstance = artboard->instance();
+        // The host's factory so a databound swap, possibly from another
+        // file, keeps the nested content on the hosting instance's session.
+        auto artboardInstance = artboard->instance<ArtboardInstance>(
+            this->artboard() != nullptr ? this->artboard()->factory()
+                                        : nullptr);
         if (artboard->stateMachineCount() > 0)
         {
 
diff --git a/src/scripted/scripted_drawable.cpp b/src/scripted/scripted_drawable.cpp
index 810f6ad..895e650 100644
--- a/src/scripted/scripted_drawable.cpp
+++ b/src/scripted/scripted_drawable.cpp
@@ -16,6 +16,16 @@
     addDirt(ComponentDirt::Paint);
 }
 
+void ScriptedDrawable::didReinit()
+{
+    // A paused editor never ticks scripts, so advance and update driven
+    // content, like gpu canvas fills, would stay blank until play; force one
+    // zero step and an update to re-record it.
+    m_isAdvanceActive = true;
+    m_forceAdvance = true;
+    addDirt(ComponentDirt::Paint | ComponentDirt::ScriptUpdate);
+}
+
 void ScriptedDrawable::draw(Renderer* renderer)
 {
     if (!draws() || m_vm == nullptr)
@@ -376,7 +386,9 @@
 bool ScriptedDrawable::advanceComponent(float elapsedSeconds,
                                         AdvanceFlags flags)
 {
-    if (elapsedSeconds == 0)
+    bool forced = m_forceAdvance;
+    m_forceAdvance = false;
+    if (elapsedSeconds == 0 && !forced)
     {
         return false;
     }
diff --git a/src/scripted/scripted_layout.cpp b/src/scripted/scripted_layout.cpp
index 20bb6b7..7c331a5 100644
--- a/src/scripted/scripted_layout.cpp
+++ b/src/scripted/scripted_layout.cpp
@@ -43,7 +43,9 @@
         LUA_OK)
     {
         // Stack: [self, status]
-        fprintf(stderr, "resize failed\n");
+        fprintf(stderr,
+                "resize failed: %s\n",
+                lua_tostring(L, -1) ? lua_tostring(L, -1) : "?");
         rive_lua_pop(L, 1);
     }
     // Stack: [self]
diff --git a/src/scripted/scripted_object.cpp b/src/scripted/scripted_object.cpp
index 2eff633..45791e0 100644
--- a/src/scripted/scripted_object.cpp
+++ b/src/scripted/scripted_object.cpp
@@ -203,29 +203,6 @@
     return result;
 }
 
-void ScriptedObject::scriptDrawCanvas()
-{
-    lua_State* L = state();
-    if (!drawsCanvas() || L == nullptr)
-    {
-        return;
-    }
-    rive_lua_pushRef(L, m_self);
-    if (static_cast<lua_Type>(lua_getfield(L, -1, "drawCanvas")) !=
-        LUA_TFUNCTION)
-    {
-        rive_lua_pop(L, 2); // non-function field + self
-        return;
-    }
-    lua_pushvalue(L, -2);
-    if (static_cast<lua_Status>(rive_lua_pcall(L, 1, 0)) != LUA_OK)
-    {
-        rive_lua_pop(L, 1);
-        return;
-    }
-    rive_lua_pop(L, 1);
-}
-
 void ScriptedObject::scriptUpdate()
 {
     lua_State* L = state();
@@ -520,8 +497,6 @@
 
 bool ScriptedObject::scriptAdvance(float elapsedSeconds) { return false; }
 
-void ScriptedObject::scriptDrawCanvas() {}
-
 void ScriptedObject::scriptUpdate() {}
 
 void ScriptedObject::scriptDispose() {}
@@ -536,6 +511,7 @@
         scriptAsset()->initScriptedObject(this);
 #ifdef WITH_RIVE_SCRIPTING
         hydrateScriptInputs();
+        didReinit();
 #endif
     }
 }
diff --git a/src/shapes/mesh.cpp b/src/shapes/mesh.cpp
index 633704b..7dbffdb 100644
--- a/src/shapes/mesh.cpp
+++ b/src/shapes/mesh.cpp
@@ -86,13 +86,11 @@
 
 Core* Mesh::clone() const
 {
-    auto factory = artboard()->factory();
     auto clone = static_cast<Mesh*>(MeshBase::clone());
     clone->m_VertexRenderBufferDirty = true;
-    clone->m_VertexRenderBuffer =
-        factory->makeRenderBuffer(RenderBufferType::vertex,
-                                  RenderBufferFlags::none,
-                                  m_Vertices.size() * sizeof(Vec2D));
+    // The vertex buffer is created lazily at first draw so it lands on the
+    // instance's factory, not the source artboard's. UV and index buffers
+    // are immutable and shared across instances.
     clone->m_UVRenderBuffer = m_UVRenderBuffer;
     clone->m_IndexRenderBuffer = m_IndexRenderBuffer;
     return clone;
@@ -178,6 +176,14 @@
                 BlendMode blendMode,
                 float opacity)
 {
+    if (m_VertexRenderBufferDirty && m_VertexRenderBuffer == nullptr &&
+        !m_Vertices.empty())
+    {
+        m_VertexRenderBuffer = artboard()->factory()->makeRenderBuffer(
+            RenderBufferType::vertex,
+            RenderBufferFlags::none,
+            m_Vertices.size() * sizeof(Vec2D));
+    }
     if (m_VertexRenderBufferDirty && m_VertexRenderBuffer != nullptr)
     {
         Vec2D* mappedVertices =
diff --git a/tests/gm/gmmain.cpp b/tests/gm/gmmain.cpp
index 2640c2c..b66c7e8 100644
--- a/tests/gm/gmmain.cpp
+++ b/tests/gm/gmmain.cpp
@@ -26,6 +26,16 @@
 static bool verbose = false;
 static int loopCount = 1;
 std::vector<std::tuple<std::function<GM*(void)>, std::string>> gmRegistry;
+// Deferred parity families. Each runs its immediate GM and every deferred
+// variant in-process and requires byte identical frames, so the machinery
+// carries no goldens of its own; the scenes' pixels are the renderer GMs' job.
+std::vector<std::tuple<std::vector<std::function<GM*(void)>>, std::string>>
+    parityRegistry;
+static int parityFailures = 0;
+// Zero demands byte identical frames. Atomic backends rasterize in a
+// nondeterministic order run to run, so they get the same small tolerance the
+// golden diffs allow instead of exactness no two of their frames ever had.
+static int parityMaxChannelDiff = 0;
 extern "C" void gms_build_registry()
 {
     // Only call gms_build_registry() once!
@@ -35,6 +45,24 @@
     extern GM* RIVE_MACRO_CONCAT(make_, NAME)();                               \
     gmRegistry.emplace_back(RIVE_MACRO_CONCAT(make_, NAME), #NAME);
 
+#define MAKE_PARITY_GM2(NAME, IMM, VAR)                                        \
+    extern GM* RIVE_MACRO_CONCAT(make_, IMM)();                                \
+    extern GM* RIVE_MACRO_CONCAT(make_, VAR)();                                \
+    parityRegistry.emplace_back(                                               \
+        std::vector<std::function<GM*(void)>>{RIVE_MACRO_CONCAT(make_, IMM),   \
+                                              RIVE_MACRO_CONCAT(make_, VAR)},  \
+        #NAME);
+
+#define MAKE_PARITY_GM3(NAME, IMM, VAR1, VAR2)                                 \
+    extern GM* RIVE_MACRO_CONCAT(make_, IMM)();                                \
+    extern GM* RIVE_MACRO_CONCAT(make_, VAR1)();                               \
+    extern GM* RIVE_MACRO_CONCAT(make_, VAR2)();                               \
+    parityRegistry.emplace_back(                                               \
+        std::vector<std::function<GM*(void)>>{RIVE_MACRO_CONCAT(make_, IMM),   \
+                                              RIVE_MACRO_CONCAT(make_, VAR1),  \
+                                              RIVE_MACRO_CONCAT(make_, VAR2)}, \
+        #NAME);
+
     // Add slow GMs first so they get more time to run in a multiprocess
     // execution.
     MAKE_GM(hittest_nonZero)
@@ -190,6 +218,11 @@
     MAKE_GM(render_canvas_persistence)
     MAKE_GM(render_canvas_prepass)
     MAKE_GM(render_canvas_prepass_multi)
+#ifdef WITH_RIVE_SCRIPTING
+    MAKE_GM(canvas_dag_chain)
+    MAKE_GM(canvas_dag_chain_reversed)
+    MAKE_GM(canvas_dag_cycle)
+#endif
 #if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) ||                \
     defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) ||                   \
     defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) ||                    \
@@ -215,8 +248,32 @@
     MAKE_GM(ore_layout_reuse)
     MAKE_GM(ore_layout_mismatch)
     MAKE_GM(ore_depth_only_pipeline)
+    MAKE_PARITY_GM3(ore_deferred_replay,
+                    ore_deferred_replay_immediate,
+                    ore_deferred_replay,
+                    ore_deferred_replay_inline)
+    MAKE_PARITY_GM2(ore_deferred_multipass,
+                    ore_deferred_multipass_immediate,
+                    ore_deferred_multipass)
+    MAKE_PARITY_GM3(ore_deferred_resource,
+                    ore_deferred_resource_immediate,
+                    ore_deferred_resource,
+                    ore_deferred_resource_unified)
+    MAKE_PARITY_GM2(ore_deferred_context,
+                    ore_deferred_context_immediate,
+                    ore_deferred_context)
+    MAKE_PARITY_GM2(render_deferred_canvas,
+                    render_deferred_canvas_immediate,
+                    render_deferred_canvas)
 #endif
 #endif
+    // 2D only so these are not gated on Ore.
+    MAKE_PARITY_GM2(serialized_replay_2d,
+                    serialized_replay_2d_immediate,
+                    serialized_replay_2d)
+    MAKE_PARITY_GM2(render_deferred_2d,
+                    render_deferred_2d_immediate,
+                    render_deferred_2d)
 }
 
 static void dump_gm(GM* gm, const std::string& name)
@@ -249,6 +306,61 @@
     }
 }
 
+static void run_parity_gm(const std::vector<std::function<GM*(void)>>& makers,
+                          const std::string& name)
+{
+    if (verbose)
+    {
+        printf("[gms] Running parity %s...\n", name.c_str());
+    }
+    std::vector<uint8_t> immediate;
+    std::vector<uint8_t> variant;
+    for (size_t i = 0; i < makers.size(); ++i)
+    {
+        std::unique_ptr<GM> gm(makers[i]());
+        if (!gm)
+        {
+            return;
+        }
+        TestingWindow::Get()->resize(gm->width(), gm->height());
+        gm->onceBeforeDraw();
+        std::vector<uint8_t>* out = i == 0 ? &immediate : &variant;
+        out->clear();
+        gm->run(name.c_str(), out);
+        if (i == 0)
+        {
+            continue;
+        }
+        bool match = variant.size() == immediate.size();
+        int worst = 0;
+        if (match && parityMaxChannelDiff > 0)
+        {
+            for (size_t b = 0; b < variant.size(); ++b)
+            {
+                int diff = std::abs(static_cast<int>(variant[b]) -
+                                    static_cast<int>(immediate[b]));
+                worst = std::max(worst, diff);
+            }
+            match = worst <= parityMaxChannelDiff;
+        }
+        else if (match)
+        {
+            match = variant == immediate;
+        }
+        if (!match)
+        {
+            parityFailures++;
+            fprintf(stderr,
+                    "[gms] PARITY FAILED: %s variant %zu does not match the "
+                    "immediate frame (worst channel diff %d, allowed %d)\n",
+                    name.c_str(),
+                    i,
+                    worst,
+                    parityMaxChannelDiff);
+        }
+    }
+}
+
 static bool contains(const std::string& str, const std::string& substr)
 {
     auto pos = str.find(substr, 0);
@@ -310,6 +422,25 @@
         emscripten_sleep(1);
 #endif
     }
+
+    for (const auto& [makers, name] : parityRegistry)
+    {
+        if (match.size() && !contains(name, match))
+        {
+            continue;
+        }
+        // Claimed like any GM so one worker runs each family; they store no
+        // golden, the claim only partitions the work.
+        if (!TestHarness::Instance().claimGMTest(name))
+        {
+            continue;
+        }
+        run_parity_gm(makers, name);
+        TestingWindow::Get()->onceAfterGM();
+#ifdef __EMSCRIPTEN__
+        emscripten_sleep(1);
+#endif
+    }
 }
 
 static bool is_arg(const char arg[],
@@ -522,6 +653,7 @@
         visibility = TestingWindow::Visibility::fullscreen;
     }
 #endif
+    parityMaxChannelDiff = backendParams.atomic ? 8 : 0;
     TestingWindow::Init(backend, backendParams, visibility, platformWindow);
 #ifndef RIVE_UNREAL // unreal calls this directly instead
     gms_build_registry();
@@ -529,7 +661,15 @@
 
     dumpGMs(std::string(match), interactive);
 
+    if (parityFailures != 0)
+    {
+        fprintf(stderr, "[gms] %d parity failures\n", parityFailures);
+        fflush(stderr);
+        abort();
+    }
+
     gmRegistry.clear();
+    parityRegistry.clear();
     TestingWindow::Destroy(); // Exercise our PLS teardown process now that
                               // we're done.
     TestHarness::Instance().shutdown();
diff --git a/tests/gm/ore_deferred_context.cpp b/tests/gm/ore_deferred_context.cpp
new file mode 100644
index 0000000..3a1283c
--- /dev/null
+++ b/tests/gm/ore_deferred_context.cpp
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2026 Rive
+ *
+ * Builds the same triangle through the same API on the real ore Context and on
+ * a DeferredOreContext whose replay creates the real resources. The goldens
+ * must be byte identical.
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+#include "ore_gm_helper.hpp"
+#if ORE_GM_HAS_BACKEND
+#include "rive/renderer/render_canvas.hpp"
+#include "rive/renderer/ore/ore_buffer.hpp"
+#include "rive/renderer/ore/ore_pipeline.hpp"
+#include "rive/renderer/ore/ore_render_pass.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_context.hpp"
+#endif
+
+using namespace rivegm;
+using namespace rive;
+using namespace rive::gpu;
+#if ORE_GM_HAS_BACKEND
+using namespace rive::ore;
+// Disambiguates from rive::cmd.
+#endif
+
+class OreDeferredContextGM : public GM
+{
+public:
+    OreDeferredContextGM(bool deferred) : GM(256, 256), m_deferred(deferred) {}
+
+    ColorInt clearColor() const override { return 0xff000000; }
+
+    void onDraw(rive::Renderer* originalRenderer) override
+    {
+        auto renderContext = TestingWindow::Get()->renderContext();
+        if (!renderContext || !m_ore.ensureContext(renderContext))
+            return;
+
+#if ORE_GM_HAS_BACKEND
+        auto& realCtx = *renderContext->getOreContext();
+        auto canvas = renderContext->makeRenderCanvas(256, 256);
+        if (!canvas)
+            return;
+
+        // Same call sites for both modes, only ctx differs. The deferred
+        // context delegates wrapCanvasTexture to the real one.
+        ore::cmd::DeferredOreContext dctx(&realCtx);
+        Context& ctx = m_deferred ? static_cast<Context&>(dctx) : realCtx;
+
+        auto colorTarget = ctx.wrapCanvasTexture(canvas.get());
+        if (!colorTarget)
+            return;
+
+        auto shader = ore_gm::loadShader(ctx, ore_gm::kTriangle);
+        if (!shader.vsModule)
+            return;
+
+        BufferDesc bd{};
+        bd.usage = BufferUsage::vertex;
+        bd.size = sizeof(ore_gm::kTriVertices);
+        bd.data = ore_gm::kTriVertices;
+        bd.label = "ore_deferred_context_vb";
+        auto vb = ctx.makeBuffer(bd);
+        if (!vb)
+            return;
+
+        ore_gm::TrianglePipeline tri(shader,
+                                     colorTarget->texture()->format(),
+                                     "ore_deferred_context_pipeline");
+        auto pipeline = ctx.makePipeline(tri.desc);
+        if (!pipeline)
+        {
+            fprintf(stderr,
+                    "[ore_deferred_context] pipeline failed: %s\n",
+                    realCtx.lastError().c_str());
+            return;
+        }
+
+        ColorAttachment ca{};
+        ca.view = colorTarget.get();
+        ca.loadOp = LoadOp::clear;
+        ca.storeOp = StoreOp::store;
+        ca.clearColor = {0.1f, 0.1f, 0.15f, 1.0f};
+        RenderPassDesc rpDesc{};
+        rpDesc.colorAttachments[0] = ca;
+        rpDesc.colorCount = 1;
+        rpDesc.label = "ore_deferred_context_pass";
+
+        auto issuePass = [&](Context& c) {
+            auto pass = c.beginRenderPass(rpDesc);
+            pass->setPipeline(pipeline.get());
+            pass->setVertexBuffer(0, vb.get());
+            pass->setViewport(0, 0, 256, 256);
+            pass->draw(3);
+            pass->finish();
+        };
+
+        if (m_deferred)
+        {
+            issuePass(dctx);
+            m_ore.beginFrame(renderContext);
+            dctx.replay(realCtx);
+            m_ore.endFrame(renderContext);
+        }
+        else
+        {
+            m_ore.beginFrame(renderContext);
+            issuePass(realCtx); // immediate GPU work needs an active frame
+            m_ore.endFrame(renderContext);
+        }
+
+        ore_gm::invalidateGLStateAfterOre(renderContext);
+
+        originalRenderer->save();
+        originalRenderer->drawImage(canvas->renderImage(),
+                                    {.filter = ImageFilter::nearest},
+                                    BlendMode::srcOver,
+                                    1);
+        originalRenderer->restore();
+#endif
+    }
+
+private:
+    bool m_deferred;
+    ore_gm::OreGMContext m_ore;
+};
+
+GMREGISTER(ore_deferred_context_immediate,
+           return new OreDeferredContextGM(false))
+GMREGISTER(ore_deferred_context, return new OreDeferredContextGM(true))
diff --git a/tests/gm/ore_deferred_context.mm b/tests/gm/ore_deferred_context.mm
new file mode 100644
index 0000000..5c02442
--- /dev/null
+++ b/tests/gm/ore_deferred_context.mm
@@ -0,0 +1,2 @@
+// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple.
+#include "ore_deferred_context.cpp"
diff --git a/tests/gm/ore_deferred_multipass.cpp b/tests/gm/ore_deferred_multipass.cpp
new file mode 100644
index 0000000..2c6d2ab
--- /dev/null
+++ b/tests/gm/ore_deferred_multipass.cpp
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2026 Rive
+ *
+ * Renders a triangle into canvas A then samples A into canvas B, immediately
+ * and via one recorded command buffer. Sequential replay must preserve the
+ * pass dependency. The goldens must be byte identical.
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+#include "ore_gm_helper.hpp"
+#if ORE_GM_HAS_BACKEND
+#include "rive/renderer/render_canvas.hpp"
+#include "rive/renderer/ore/ore_buffer.hpp"
+#include "rive/renderer/ore/ore_sampler.hpp"
+#include "rive/renderer/ore/ore_bind_group.hpp"
+#include "rive/renderer/ore/ore_pipeline.hpp"
+#include "rive/renderer/ore/ore_render_pass.hpp"
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp"
+#include "rive/renderer/ore/cmd/ore_replay.hpp"
+#endif
+
+using namespace rivegm;
+using namespace rive;
+using namespace rive::gpu;
+#if ORE_GM_HAS_BACKEND
+using namespace rive::ore;
+// Disambiguates from rive::cmd.
+#endif
+
+enum class MPMode
+{
+    kImmediate,
+    kDeferred,
+};
+
+class OreDeferredMultipassGM : public GM
+{
+public:
+    OreDeferredMultipassGM(MPMode mode) : GM(256, 256), m_mode(mode) {}
+
+    ColorInt clearColor() const override { return 0xff000000; }
+
+    void onDraw(rive::Renderer* originalRenderer) override
+    {
+        auto renderContext = TestingWindow::Get()->renderContext();
+        if (!renderContext || !m_ore.ensureContext(renderContext))
+            return;
+
+#if ORE_GM_HAS_BACKEND
+        auto& ctx = *renderContext->getOreContext();
+
+        // Canvas A is the producer and B the consumer.
+        auto canvasA = renderContext->makeRenderCanvas(256, 256);
+        auto canvasB = renderContext->makeRenderCanvas(256, 256);
+        if (!canvasA || !canvasB)
+            return;
+        auto targetA = ctx.wrapCanvasTexture(canvasA.get());
+        auto targetB = ctx.wrapCanvasTexture(canvasB.get());
+        if (!targetA || !targetB)
+            return;
+
+        // Pass 1 resources.
+        BufferDesc bd{};
+        bd.usage = BufferUsage::vertex;
+        bd.size = sizeof(ore_gm::kTriVertices);
+        bd.data = ore_gm::kTriVertices;
+        bd.label = "ore_deferred_multipass_vb";
+        auto vb = ctx.makeBuffer(bd);
+        if (!vb)
+            return;
+
+        auto triShader = ore_gm::loadShader(ctx, ore_gm::kTriangle);
+        if (!triShader.vsModule)
+            return;
+
+        ore_gm::TrianglePipeline tri(triShader,
+                                     targetA->texture()->format(),
+                                     "ore_deferred_multipass_tri");
+        auto triPipeline = ctx.makePipeline(tri.desc);
+        if (!triPipeline)
+        {
+            fprintf(stderr,
+                    "[ore_deferred_multipass] tri pipeline failed: %s\n",
+                    ctx.lastError().c_str());
+            return;
+        }
+
+        // Pass 2 resources.
+        SamplerDesc sampDesc{};
+        sampDesc.minFilter = Filter::nearest;
+        sampDesc.magFilter = Filter::nearest;
+        auto sampler = ctx.makeSampler(sampDesc);
+
+        auto imgShader = ore_gm::loadShader(ctx, ore_gm::kImageView);
+        if (!imgShader.vsModule)
+            return;
+        auto layout1 =
+            ore_gm::makeLayoutFromShader(ctx, imgShader.vsModule.get(), 1);
+        auto layout2 =
+            ore_gm::makeLayoutFromShader(ctx, imgShader.vsModule.get(), 2);
+        BindGroupLayout* layouts[] = {nullptr, layout1.get(), layout2.get()};
+
+        PipelineDesc imgPd{};
+        imgPd.vertexModule = imgShader.vsModule.get();
+        imgPd.fragmentModule = imgShader.psModule.get();
+        imgPd.vertexEntryPoint = imgShader.vsEntryPoint;
+        imgPd.fragmentEntryPoint = imgShader.fsEntryPoint;
+        imgPd.vertexBufferCount = 0;
+        imgPd.topology = PrimitiveTopology::triangleList;
+        imgPd.colorTargets[0].format = targetB->texture()->format();
+        imgPd.colorCount = 1;
+        imgPd.depthStencil.depthCompare = CompareFunction::always;
+        imgPd.depthStencil.depthWriteEnabled = false;
+        imgPd.bindGroupLayouts = layouts;
+        imgPd.bindGroupLayoutCount = 3;
+        imgPd.label = "ore_deferred_multipass_img";
+        auto imgPipeline = ctx.makePipeline(imgPd);
+        if (!imgPipeline)
+        {
+            fprintf(stderr,
+                    "[ore_deferred_multipass] img pipeline failed: %s\n",
+                    ctx.lastError().c_str());
+            return;
+        }
+
+        BindGroupDesc texBGDesc{};
+        texBGDesc.layout = layout1.get();
+        BindGroupDesc::TexEntry texEntry{};
+        texEntry.slot = 0;
+        texEntry.view = targetA.get();
+        texBGDesc.textures = &texEntry;
+        texBGDesc.textureCount = 1;
+        auto texBG = ctx.makeBindGroup(texBGDesc);
+
+        BindGroupDesc sampBGDesc{};
+        sampBGDesc.layout = layout2.get();
+        BindGroupDesc::SampEntry sampEntry{};
+        sampEntry.slot = 0;
+        sampEntry.sampler = sampler.get();
+        sampBGDesc.samplers = &sampEntry;
+        sampBGDesc.samplerCount = 1;
+        auto sampBG = ctx.makeBindGroup(sampBGDesc);
+
+        ColorAttachment caA{};
+        caA.view = targetA.get();
+        caA.loadOp = LoadOp::clear;
+        caA.storeOp = StoreOp::store;
+        caA.clearColor = {0.1f, 0.1f, 0.15f, 1.0f};
+        RenderPassDesc rpA{};
+        rpA.colorAttachments[0] = caA;
+        rpA.colorCount = 1;
+        rpA.label = "ore_deferred_multipass_passA";
+
+        ColorAttachment caB{};
+        caB.view = targetB.get();
+        caB.loadOp = LoadOp::clear;
+        caB.storeOp = StoreOp::store;
+        caB.clearColor = {0, 0, 0, 1};
+        RenderPassDesc rpB{};
+        rpB.colorAttachments[0] = caB;
+        rpB.colorCount = 1;
+        rpB.label = "ore_deferred_multipass_passB";
+
+        m_ore.beginFrame(renderContext);
+
+        if (m_mode == MPMode::kDeferred)
+        {
+            ore::cmd::OreCommandBuffer cmdBuf;
+            {
+                ore::cmd::RenderPassRecording p1(&ctx, &cmdBuf, rpA);
+                p1.setPipeline(triPipeline.get());
+                p1.setVertexBuffer(0, vb.get());
+                p1.setViewport(0, 0, 256, 256);
+                p1.draw(3);
+                p1.finish();
+
+                ore::cmd::RenderPassRecording p2(&ctx, &cmdBuf, rpB);
+                p2.setPipeline(imgPipeline.get());
+                p2.setBindGroup(1, texBG.get());
+                p2.setBindGroup(2, sampBG.get());
+                p2.setViewport(0, 0, 256, 256);
+                p2.draw(6);
+                p2.finish();
+            }
+            ore::cmd::replayCommandBuffer(ctx, cmdBuf);
+        }
+        else
+        {
+            auto p1 = ctx.beginRenderPass(rpA);
+            p1->setPipeline(triPipeline.get());
+            p1->setVertexBuffer(0, vb.get());
+            p1->setViewport(0, 0, 256, 256);
+            p1->draw(3);
+            p1->finish();
+
+            auto p2 = ctx.beginRenderPass(rpB);
+            p2->setPipeline(imgPipeline.get());
+            p2->setBindGroup(1, texBG.get());
+            p2->setBindGroup(2, sampBG.get());
+            p2->setViewport(0, 0, 256, 256);
+            p2->draw(6);
+            p2->finish();
+        }
+
+        m_ore.endFrame(renderContext);
+        ore_gm::invalidateGLStateAfterOre(renderContext);
+
+        originalRenderer->save();
+        originalRenderer->drawImage(canvasB->renderImage(),
+                                    {.filter = ImageFilter::nearest},
+                                    BlendMode::srcOver,
+                                    1);
+        originalRenderer->restore();
+#endif
+    }
+
+private:
+    MPMode m_mode;
+    ore_gm::OreGMContext m_ore;
+};
+
+GMREGISTER(ore_deferred_multipass_immediate,
+           return new OreDeferredMultipassGM(MPMode::kImmediate))
+GMREGISTER(ore_deferred_multipass,
+           return new OreDeferredMultipassGM(MPMode::kDeferred))
diff --git a/tests/gm/ore_deferred_multipass.mm b/tests/gm/ore_deferred_multipass.mm
new file mode 100644
index 0000000..1ffe6d4
--- /dev/null
+++ b/tests/gm/ore_deferred_multipass.mm
@@ -0,0 +1,2 @@
+// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple.
+#include "ore_deferred_multipass.cpp"
diff --git a/tests/gm/ore_deferred_replay.cpp b/tests/gm/ore_deferred_replay.cpp
new file mode 100644
index 0000000..268a3c7
--- /dev/null
+++ b/tests/gm/ore_deferred_replay.cpp
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2026 Rive
+ *
+ * Renders one triangle immediately, via record and replay, and via the
+ * context's inline deferred flag. The goldens must be byte identical.
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+#include "ore_gm_helper.hpp"
+#if ORE_GM_HAS_BACKEND
+#include "rive/renderer/render_canvas.hpp"
+#include "rive/renderer/ore/ore_buffer.hpp"
+#include "rive/renderer/ore/ore_pipeline.hpp"
+#include "rive/renderer/ore/ore_render_pass.hpp"
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp"
+#include "rive/renderer/ore/cmd/ore_replay.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_render_pass.hpp"
+#endif
+
+using namespace rivegm;
+using namespace rive;
+using namespace rive::gpu;
+#if ORE_GM_HAS_BACKEND
+using namespace rive::ore;
+// Disambiguates from rive::cmd.
+#endif
+
+// kInlineDeferred drives the context deferred flag through the same chooser
+// the Lua beginRenderPass call site uses.
+enum class ReplayMode
+{
+    kImmediate,
+    kRecordReplay,
+    kInlineDeferred,
+};
+
+class OreDeferredReplayGM : public GM
+{
+public:
+    OreDeferredReplayGM(ReplayMode mode) : GM(256, 256), m_mode(mode) {}
+
+    ColorInt clearColor() const override { return 0xff000000; }
+
+    void onDraw(rive::Renderer* originalRenderer) override
+    {
+        auto renderContext = TestingWindow::Get()->renderContext();
+        if (!renderContext || !m_ore.ensureContext(renderContext))
+            return;
+
+#if ORE_GM_HAS_BACKEND
+        auto& ctx = *renderContext->getOreContext();
+        auto canvas = renderContext->makeRenderCanvas(256, 256);
+        if (!canvas)
+            return;
+        auto colorTarget = ctx.wrapCanvasTexture(canvas.get());
+        if (!colorTarget)
+            return;
+
+        BufferDesc bd{};
+        bd.usage = BufferUsage::vertex;
+        bd.size = sizeof(ore_gm::kTriVertices);
+        bd.data = ore_gm::kTriVertices;
+        bd.label = "ore_deferred_replay_vb";
+        auto vb = ctx.makeBuffer(bd);
+        if (!vb)
+            return;
+
+        auto shader = ore_gm::loadShader(ctx, ore_gm::kTriangle);
+        if (!shader.vsModule)
+            return;
+
+        ore_gm::TrianglePipeline tri(shader,
+                                     colorTarget->texture()->format(),
+                                     "ore_deferred_replay_pipeline");
+        auto pipeline = ctx.makePipeline(tri.desc);
+        if (!pipeline)
+        {
+            fprintf(stderr,
+                    "[ore_deferred_replay] pipeline failed: %s\n",
+                    ctx.lastError().c_str());
+            return;
+        }
+
+        ColorAttachment ca{};
+        ca.view = colorTarget.get();
+        ca.loadOp = LoadOp::clear;
+        ca.storeOp = StoreOp::store;
+        ca.clearColor = {0.1f, 0.1f, 0.15f, 1.0f};
+
+        RenderPassDesc rpDesc{};
+        rpDesc.colorAttachments[0] = ca;
+        rpDesc.colorCount = 1;
+        rpDesc.label = "ore_deferred_replay_pass";
+
+        m_ore.beginFrame(renderContext);
+
+        if (m_mode == ReplayMode::kRecordReplay)
+        {
+            // Same calls as the immediate branch below.
+            ore::cmd::OreCommandBuffer cmdBuf;
+            {
+                ore::cmd::RenderPassRecording rec(&ctx, &cmdBuf, rpDesc);
+                rec.setPipeline(pipeline.get());
+                rec.setVertexBuffer(0, vb.get());
+                rec.setViewport(0, 0, 256, 256);
+                rec.draw(3);
+                rec.finish();
+            }
+            ore::cmd::replayCommandBuffer(ctx, cmdBuf);
+        }
+        else if (m_mode == ReplayMode::kInlineDeferred)
+        {
+            // finish records and then inline replays.
+            ctx.setDeferredRecording(true);
+            auto pass =
+                ore::cmd::beginRenderPassRecordingOrImmediate(ctx, rpDesc);
+            pass->setPipeline(pipeline.get());
+            pass->setVertexBuffer(0, vb.get());
+            pass->setViewport(0, 0, 256, 256);
+            pass->draw(3);
+            pass->finish();
+            ctx.setDeferredRecording(false);
+        }
+        else
+        {
+            auto pass = ctx.beginRenderPass(rpDesc);
+            pass->setPipeline(pipeline.get());
+            pass->setVertexBuffer(0, vb.get());
+            pass->setViewport(0, 0, 256, 256);
+            pass->draw(3);
+            pass->finish();
+        }
+
+        m_ore.endFrame(renderContext);
+        ore_gm::invalidateGLStateAfterOre(renderContext);
+
+        originalRenderer->save();
+        originalRenderer->drawImage(canvas->renderImage(),
+                                    {.filter = ImageFilter::nearest},
+                                    BlendMode::srcOver,
+                                    1);
+        originalRenderer->restore();
+#endif
+    }
+
+private:
+    ReplayMode m_mode;
+    ore_gm::OreGMContext m_ore;
+};
+
+GMREGISTER(ore_deferred_replay_immediate,
+           return new OreDeferredReplayGM(ReplayMode::kImmediate))
+GMREGISTER(ore_deferred_replay,
+           return new OreDeferredReplayGM(ReplayMode::kRecordReplay))
+GMREGISTER(ore_deferred_replay_inline,
+           return new OreDeferredReplayGM(ReplayMode::kInlineDeferred))
diff --git a/tests/gm/ore_deferred_replay.mm b/tests/gm/ore_deferred_replay.mm
new file mode 100644
index 0000000..e3eeca7
--- /dev/null
+++ b/tests/gm/ore_deferred_replay.mm
@@ -0,0 +1,2 @@
+// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple.
+#include "ore_deferred_replay.cpp"
diff --git a/tests/gm/ore_deferred_resource.cpp b/tests/gm/ore_deferred_resource.cpp
new file mode 100644
index 0000000..23f7e33
--- /dev/null
+++ b/tests/gm/ore_deferred_resource.cpp
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2026 Rive
+ *
+ * Renders the same triangle from an immediate vertex buffer, a replay created
+ * buffer, and a deferred buffer remapped at unified replay, all through the
+ * DeferredOreContext. The goldens must be byte identical.
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+#include "ore_gm_helper.hpp"
+#if ORE_GM_HAS_BACKEND
+#include "rive/renderer/render_canvas.hpp"
+#include "rive/renderer/ore/ore_buffer.hpp"
+#include "rive/renderer/ore/ore_pipeline.hpp"
+#include "rive/renderer/ore/ore_render_pass.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_context.hpp"
+#include <memory>
+#endif
+
+using namespace rivegm;
+using namespace rive;
+using namespace rive::gpu;
+#if ORE_GM_HAS_BACKEND
+using namespace rive::ore;
+// Disambiguates from rive::cmd.
+#endif
+
+// kReplayBuffer creates the buffer via replay then draws immediately.
+// kUnified records the whole pass against a deferred buffer and a single
+// replay creates the real buffer and remaps it.
+enum class ResMode
+{
+    kImmediate,
+    kReplayBuffer,
+    kUnified,
+};
+
+class OreDeferredResourceGM : public GM
+{
+public:
+    OreDeferredResourceGM(ResMode mode) : GM(256, 256), m_mode(mode) {}
+
+    ColorInt clearColor() const override { return 0xff000000; }
+
+    void onDraw(rive::Renderer* originalRenderer) override
+    {
+        auto renderContext = TestingWindow::Get()->renderContext();
+        if (!renderContext || !m_ore.ensureContext(renderContext))
+            return;
+
+#if ORE_GM_HAS_BACKEND
+        auto& ctx = *renderContext->getOreContext();
+        auto canvas = renderContext->makeRenderCanvas(256, 256);
+        if (!canvas)
+            return;
+        auto colorTarget = ctx.wrapCanvasTexture(canvas.get());
+        if (!colorTarget)
+            return;
+
+        BufferDesc bd{};
+        bd.usage = BufferUsage::vertex;
+        bd.size = sizeof(ore_gm::kTriVertices);
+        bd.data = ore_gm::kTriVertices;
+        bd.label = "ore_deferred_resource_vb";
+
+        auto shader = ore_gm::loadShader(ctx, ore_gm::kTriangle);
+        if (!shader.vsModule)
+            return;
+
+        ore_gm::TrianglePipeline tri(shader,
+                                     colorTarget->texture()->format(),
+                                     "ore_deferred_resource_pipeline");
+        auto pipeline = ctx.makePipeline(tri.desc);
+        if (!pipeline)
+        {
+            fprintf(stderr,
+                    "[ore_deferred_resource] pipeline failed: %s\n",
+                    ctx.lastError().c_str());
+            return;
+        }
+
+        ColorAttachment ca{};
+        ca.view = colorTarget.get();
+        ca.loadOp = LoadOp::clear;
+        ca.storeOp = StoreOp::store;
+        ca.clearColor = {0.1f, 0.1f, 0.15f, 1.0f};
+        RenderPassDesc rpDesc{};
+        rpDesc.colorAttachments[0] = ca;
+        rpDesc.colorCount = 1;
+        rpDesc.label = "ore_deferred_resource_pass";
+
+        if (m_mode == ResMode::kUnified)
+        {
+            // The pass is recorded before any real buffer exists; the real
+            // pipeline and target resolve by flagged index at replay.
+            ore::cmd::DeferredOreContext dctx(&ctx);
+            auto vb = dctx.makeBuffer(bd);
+            {
+                auto pass = dctx.beginRenderPass(rpDesc);
+                pass->setPipeline(pipeline.get());
+                pass->setVertexBuffer(0, vb.get());
+                pass->setViewport(0, 0, 256, 256);
+                pass->draw(3);
+                pass->finish();
+            }
+
+            m_ore.beginFrame(renderContext);
+            dctx.replay(ctx);
+            m_ore.endFrame(renderContext);
+        }
+        else
+        {
+            rcp<Buffer> vb;
+            ore::cmd::OreResident table;
+            std::unique_ptr<ore::cmd::DeferredOreContext> dctx;
+            if (m_mode == ResMode::kReplayBuffer)
+            {
+                dctx = std::make_unique<ore::cmd::DeferredOreContext>(&ctx);
+                auto deferredVb = dctx->makeBuffer(bd);
+                dctx->replayFrame(ctx, table);
+                auto* real = table.get(
+                    static_cast<ore::cmd::DeferredBuffer*>(deferredVb.get())
+                        ->clientHandle());
+                vb = ref_rcp(static_cast<Buffer*>(real));
+            }
+            else
+            {
+                vb = ctx.makeBuffer(bd);
+            }
+            if (!vb)
+                return;
+
+            m_ore.beginFrame(renderContext);
+            auto pass = ctx.beginRenderPass(rpDesc);
+            pass->setPipeline(pipeline.get());
+            pass->setVertexBuffer(0, vb.get());
+            pass->setViewport(0, 0, 256, 256);
+            pass->draw(3);
+            pass->finish();
+            m_ore.endFrame(renderContext);
+        }
+        ore_gm::invalidateGLStateAfterOre(renderContext);
+
+        originalRenderer->save();
+        originalRenderer->drawImage(canvas->renderImage(),
+                                    {.filter = ImageFilter::nearest},
+                                    BlendMode::srcOver,
+                                    1);
+        originalRenderer->restore();
+#endif
+    }
+
+private:
+    ResMode m_mode;
+    ore_gm::OreGMContext m_ore;
+};
+
+GMREGISTER(ore_deferred_resource_immediate,
+           return new OreDeferredResourceGM(ResMode::kImmediate))
+GMREGISTER(ore_deferred_resource,
+           return new OreDeferredResourceGM(ResMode::kReplayBuffer))
+GMREGISTER(ore_deferred_resource_unified,
+           return new OreDeferredResourceGM(ResMode::kUnified))
diff --git a/tests/gm/ore_deferred_resource.mm b/tests/gm/ore_deferred_resource.mm
new file mode 100644
index 0000000..a6e1f3a
--- /dev/null
+++ b/tests/gm/ore_deferred_resource.mm
@@ -0,0 +1,2 @@
+// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple.
+#include "ore_deferred_resource.cpp"
diff --git a/tests/gm/ore_gm_helper.hpp b/tests/gm/ore_gm_helper.hpp
index ebe1617..909ced1 100644
--- a/tests/gm/ore_gm_helper.hpp
+++ b/tests/gm/ore_gm_helper.hpp
@@ -13,16 +13,23 @@
 #include "rive/renderer/render_context.hpp"
 #include <array>
 #include <cassert>
+#include <cstddef>
 #include <cstdio>
 #include <cstring>
 #include <unordered_map>
 
-// Include Ore headers when any backend is compiled.
-// Multiple backends may be active simultaneously (e.g. Metal + GL on macOS).
+// True when any Ore backend is compiled. Multiple backends may be active
+// simultaneously (e.g. Metal + GL on macOS). Source of truth for every GM.
 #if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) ||                \
     defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) ||                   \
     defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) ||                    \
     defined(ORE_BACKEND_RHI)
+#define ORE_GM_HAS_BACKEND 1
+#else
+#define ORE_GM_HAS_BACKEND 0
+#endif
+
+#if ORE_GM_HAS_BACKEND
 #include "rive/renderer/ore/ore_context.hpp"
 #include <memory>
 #endif
@@ -67,10 +74,7 @@
 #include "rive/renderer/vulkan/render_context_vulkan_impl.hpp"
 #endif
 
-#if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) ||                \
-    defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) ||                   \
-    defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) ||                    \
-    defined(ORE_BACKEND_RHI)
+#if ORE_GM_HAS_BACKEND
 #include "ore_gm_shaders.rstb.hpp"
 #include "rive/renderer/ore/ore_rstb_entry_container.hpp"
 #include "rive/assets/shader_asset.hpp"
@@ -141,10 +145,7 @@
     bool ensureContext(rive::gpu::RenderContext* renderContext)
     {
 
-#if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) ||                \
-    defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) ||                   \
-    defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) ||                    \
-    defined(ORE_BACKEND_RHI)
+#if ORE_GM_HAS_BACKEND
         if (!renderContext || !isOreBackendActive())
             return false;
 
@@ -314,10 +315,7 @@
 // ShaderTarget constants (must match RSTB format):
 //   0=WGSL, 1=GLSL_ES3, 2=MSL, 3=HLSL_SM5, 5=SPIR-V
 
-#if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) ||                \
-    defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) ||                   \
-    defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) ||                    \
-    defined(ORE_BACKEND_RHI)
+#if ORE_GM_HAS_BACKEND
 
 // Keeps GM shader asset ids clear of riv asset ids and 0 (unset).
 constexpr uint32_t kOreGMShaderAssetIdBase = 0x80000000u;
@@ -731,6 +729,53 @@
     return ctx.makeBindGroupLayout(desc);
 }
 
+// Shared triangle pass used by the deferred GMs.
+struct TriVertex
+{
+    float x, y;
+    float r, g, b, a;
+};
+
+inline constexpr TriVertex kTriVertices[] = {
+    {0.0f, 0.6f, 1.0f, 0.2f, 0.2f, 1.0f},
+    {-0.6f, -0.6f, 0.2f, 1.0f, 0.2f, 1.0f},
+    {0.6f, -0.6f, 0.2f, 0.2f, 1.0f, 1.0f},
+};
+
+// desc points into attrs and layout, so this object must stay alive through
+// makePipeline and cannot be copied.
+struct TrianglePipeline
+{
+    TrianglePipeline(const OreGMShaderResult& shader,
+                     rive::ore::TextureFormat targetFormat,
+                     const char* label)
+    {
+        layout.stride = sizeof(TriVertex);
+        layout.stepMode = rive::ore::VertexStepMode::vertex;
+        layout.attributes = attrs;
+        layout.attributeCount = 2;
+        desc.vertexModule = shader.vsModule.get();
+        desc.fragmentModule = shader.psModule.get();
+        desc.vertexEntryPoint = shader.vsEntryPoint;
+        desc.fragmentEntryPoint = shader.fsEntryPoint;
+        desc.vertexBuffers = &layout;
+        desc.vertexBufferCount = 1;
+        desc.topology = rive::ore::PrimitiveTopology::triangleList;
+        desc.colorTargets[0].format = targetFormat;
+        desc.colorCount = 1;
+        desc.label = label;
+    }
+    TrianglePipeline(const TrianglePipeline&) = delete;
+    TrianglePipeline& operator=(const TrianglePipeline&) = delete;
+
+    rive::ore::VertexAttribute attrs[2] = {
+        {rive::ore::VertexFormat::float2, offsetof(TriVertex, x), 0},
+        {rive::ore::VertexFormat::float4, offsetof(TriVertex, r), 1},
+    };
+    rive::ore::VertexBufferLayout layout{};
+    rive::ore::PipelineDesc desc{};
+};
+
 #endif // ORE_BACKEND_*
 
 } // namespace ore_gm
diff --git a/tests/gm/ore_gm_shaders.rstb b/tests/gm/ore_gm_shaders.rstb
new file mode 100644
index 0000000..efae04c
--- /dev/null
+++ b/tests/gm/ore_gm_shaders.rstb
Binary files differ
diff --git a/tests/gm/ore_render_deferred_canvas.cpp b/tests/gm/ore_render_deferred_canvas.cpp
new file mode 100644
index 0000000..a0ce638
--- /dev/null
+++ b/tests/gm/ore_render_deferred_canvas.cpp
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2026 Rive
+ *
+ * Ore clears a canvas and the 2D screen draws that canvas image, immediately
+ * and through a DeferredSession drained by the shared DeferredReplayer. The
+ * canvas image travels the stream as a shared id, never a pointer. The goldens
+ * must be byte identical.
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+#include "ore_gm_helper.hpp"
+#if ORE_GM_HAS_BACKEND
+#include "rive/renderer/render_canvas.hpp"
+#include "rive/renderer/ore/ore_render_pass.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_context.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#endif
+
+using namespace rivegm;
+using namespace rive;
+using namespace rive::gpu;
+#if ORE_GM_HAS_BACKEND
+using namespace rive::ore;
+// Disambiguates from rive::cmd.
+
+// The GM is handed an already open screen renderer, so beginScreenFrame just
+// returns it.
+class GMCanvasSink : public rive::cmd::DeferredFrameSink
+{
+public:
+    GMCanvasSink(rive::gpu::RenderContext* rc,
+                 rive::Renderer* screen,
+                 ore_gm::OreGMContext* oreCtx) :
+        m_rc(rc), m_screen(screen), m_ore(oreCtx)
+    {}
+    rive::Factory* factory() override
+    {
+        return TestingWindow::Get()->factory();
+    }
+    // The GM hands over one already open screen renderer, so there is nothing
+    // to dispatch on.
+    rive::Renderer* beginScreenFrame(uint64_t target) override
+    {
+        assert(target == 0);
+        return m_screen;
+    }
+    void beginOreFrame() override { m_ore->beginFrame(m_rc); }
+    void endOreFrame() override { m_ore->endFrame(m_rc); }
+    void afterOreFrame() override { ore_gm::invalidateGLStateAfterOre(m_rc); }
+
+private:
+    rive::gpu::RenderContext* m_rc;
+    rive::Renderer* m_screen;
+    ore_gm::OreGMContext* m_ore;
+};
+#endif
+
+class RenderDeferredCanvasGM : public GM
+{
+public:
+    RenderDeferredCanvasGM(bool deferred) : GM(256, 256), m_deferred(deferred)
+    {}
+
+    ColorInt clearColor() const override { return 0xff202028; }
+
+    void onDraw(rive::Renderer* originalRenderer) override
+    {
+        auto renderContext = TestingWindow::Get()->renderContext();
+        if (!renderContext || !m_ore.ensureContext(renderContext))
+        {
+            return;
+        }
+
+#if ORE_GM_HAS_BACKEND
+        auto& realCtx = *renderContext->getOreContext();
+        auto canvas = renderContext->makeRenderCanvas(200, 200);
+        if (!canvas)
+        {
+            return;
+        }
+
+        ImageSampler sampler{};
+        sampler.filter = ImageFilter::nearest;
+
+        auto recordClear = [&](Context& ctx, TextureView* view) {
+            ColorAttachment ca{};
+            ca.view = view;
+            ca.loadOp = LoadOp::clear;
+            ca.storeOp = StoreOp::store;
+            ca.clearColor = {0.10f, 0.70f, 0.55f, 1.0f};
+            RenderPassDesc rp{};
+            rp.colorAttachments[0] = ca;
+            rp.colorCount = 1;
+            auto pass = ctx.beginRenderPass(rp);
+            pass->setViewport(0, 0, 200, 200);
+            pass->finish();
+        };
+        auto drawCanvasToScreen = [&](Renderer* r) {
+            r->save();
+            r->translate(28, 28);
+            r->drawImage(canvas->renderImage(),
+                         sampler,
+                         BlendMode::srcOver,
+                         1.0f);
+            r->restore();
+        };
+
+        if (m_deferred)
+        {
+            // Same DeferredReplayer the goldens host and editor use. The marker
+            // orders the Ore replay before the screen draw.
+            rive::cmd::DeferredSession session(&realCtx);
+
+            auto view = session.oreContext().wrapCanvasTexture(canvas.get());
+            recordClear(session.oreContext(), view.get());
+            session.recordOreReplayMarker();
+
+            auto dr = session.makeScreenRenderer();
+            drawCanvasToScreen(dr.get());
+
+            // Snapshot replay is the same path a threaded consumer takes.
+            rive::cmd::DeferredFrame frame = rive::cmd::snapshotFrame(session);
+            GMCanvasSink sink(renderContext, originalRenderer, &m_ore);
+            rive::cmd::DeferredReplayer replayer;
+            replayer.replayFrame(frame, sink);
+        }
+        else
+        {
+            auto view = realCtx.wrapCanvasTexture(canvas.get());
+            m_ore.beginFrame(renderContext);
+            recordClear(realCtx, view.get());
+            m_ore.endFrame(renderContext);
+            ore_gm::invalidateGLStateAfterOre(renderContext);
+            drawCanvasToScreen(originalRenderer);
+        }
+#endif
+    }
+
+private:
+    bool m_deferred;
+    ore_gm::OreGMContext m_ore;
+};
+
+GMREGISTER(render_deferred_canvas_immediate,
+           return new RenderDeferredCanvasGM(false))
+GMREGISTER(render_deferred_canvas, return new RenderDeferredCanvasGM(true))
diff --git a/tests/gm/ore_render_deferred_canvas.mm b/tests/gm/ore_render_deferred_canvas.mm
new file mode 100644
index 0000000..a62f22b
--- /dev/null
+++ b/tests/gm/ore_render_deferred_canvas.mm
@@ -0,0 +1,2 @@
+// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple.
+#include "ore_render_deferred_canvas.cpp"
diff --git a/tests/gm/render_canvas_dag.cpp b/tests/gm/render_canvas_dag.cpp
new file mode 100644
index 0000000..d501784
--- /dev/null
+++ b/tests/gm/render_canvas_dag.cpp
@@ -0,0 +1,291 @@
+/*
+ * Copyright 2026 Rive
+ *
+ * A canvas sampling another canvas replays after its writer regardless of
+ * record order, so the reversed recording must match the in-order one. The
+ * cycle GM pins the demoted back edge to previous-frame sampling.
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+
+#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS)
+
+#include "rive/renderer/render_canvas.hpp"
+#include "rive/renderer/rive_renderer.hpp"
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+
+using namespace rivegm;
+using namespace rive;
+using namespace rive::gpu;
+
+namespace
+{
+// DeferredFrameSink over the GM harness. The harness frame is already open,
+// so the first sink action flushes it and later frames resume with preserve.
+class DagGMSink : public rive::cmd::DeferredFrameSink
+{
+public:
+    DagGMSink(RenderContext* rc,
+              const RenderContext::FrameDescriptor& mainDesc) :
+        m_rc(rc), m_mainDesc(mainDesc)
+    {}
+
+    Factory* factory() override { return TestingWindow::Get()->factory(); }
+
+    // The GM harness owns one main render target.
+    Renderer* beginScreenFrame(uint64_t target) override
+    {
+        assert(target == 0);
+        flushOpenFrame();
+        auto d = m_mainDesc;
+        d.loadAction = LoadAction::preserveRenderTarget;
+        m_rc->beginFrame(std::move(d));
+        m_frameOpen = true;
+        m_screen = std::make_unique<RiveRenderer>(m_rc);
+        return m_screen.get();
+    }
+
+    Renderer* beginCanvasContent(RenderCanvas* canvas,
+                                 uint32_t clearColor) override
+    {
+        flushOpenFrame();
+        m_activeCanvas = canvas;
+        auto d = m_mainDesc;
+        d.renderTargetWidth = canvas->width();
+        d.renderTargetHeight = canvas->height();
+        d.loadAction = LoadAction::clear;
+        d.clearColor = clearColor;
+        m_rc->beginFrame(std::move(d));
+        m_frameOpen = true;
+        m_canvasRenderer = std::make_unique<RiveRenderer>(m_rc);
+        return m_canvasRenderer.get();
+    }
+
+    void endCanvasContent() override
+    {
+        if (m_activeCanvas == nullptr)
+        {
+            return;
+        }
+        TestingWindow::Get()->flushPLSContext(m_activeCanvas->renderTarget());
+        m_frameOpen = false;
+        m_canvasRenderer = nullptr;
+        m_activeCanvas = nullptr;
+    }
+
+private:
+    // The harness (or the previous replay) leaves the main frame open.
+    void flushOpenFrame()
+    {
+        if (!m_flushedHarnessFrame || m_frameOpen)
+        {
+            TestingWindow::Get()->flushPLSContext();
+            m_flushedHarnessFrame = true;
+            m_frameOpen = false;
+        }
+    }
+
+    RenderContext* m_rc;
+    RenderContext::FrameDescriptor m_mainDesc;
+    bool m_flushedHarnessFrame = false;
+    bool m_frameOpen = false;
+    std::unique_ptr<RiveRenderer> m_screen;
+    std::unique_ptr<RiveRenderer> m_canvasRenderer;
+    RenderCanvas* m_activeCanvas = nullptr;
+};
+
+rcp<RenderPath> ovalPath(rive::cmd::DeferredSession& session, AABB bounds)
+{
+    RawPath raw;
+    raw.addOval(bounds);
+    return session.makeRenderPath(raw, FillRule::nonZero);
+}
+
+rcp<RenderPaint> solidPaint(rive::cmd::DeferredSession& session, ColorInt color)
+{
+    auto paint = session.makeRenderPaint();
+    paint->color(color);
+    return paint;
+}
+
+void drawCanvasImage(Renderer* r,
+                     RenderCanvas* canvas,
+                     float x,
+                     float y,
+                     bool flip)
+{
+    r->save();
+    r->translate(x, y);
+    if (flip)
+    {
+        r->translate(0, static_cast<float>(canvas->height()));
+        r->scale(1, -1);
+    }
+    r->drawImage(canvas->renderImage(),
+                 {.filter = ImageFilter::nearest},
+                 BlendMode::srcOver,
+                 1.0f);
+    r->restore();
+}
+
+void replayFrameThroughGM(rive::cmd::DeferredSession& session,
+                          rive::cmd::DeferredReplayer& replayer,
+                          RenderContext* rc,
+                          const RenderContext::FrameDescriptor& mainDesc)
+{
+    rive::cmd::DeferredFrame frame = rive::cmd::snapshotFrame(session);
+    session.resetFrame();
+    DagGMSink sink(rc, mainDesc);
+    replayer.replayFrame(frame, sink);
+}
+} // namespace
+
+// Canvas B samples canvas A; reversed records B's bracket first. Both GMs
+// must produce identical pixels: the schedule, not record order, decides.
+class CanvasDagChainGM : public GM
+{
+public:
+    CanvasDagChainGM(bool reversed) : GM(256, 256), m_reversed(reversed) {}
+
+    ColorInt clearColor() const override { return 0xff202028; }
+
+    void onDraw(Renderer*) override
+    {
+        auto rc = TestingWindow::Get()->renderContext();
+        if (!rc)
+        {
+            return;
+        }
+        auto canvasA = rc->makeRenderCanvas(128, 128);
+        auto canvasB = rc->makeRenderCanvas(128, 128);
+        if (!canvasA || !canvasB)
+        {
+            return;
+        }
+        auto mainDesc = rc->frameDescriptor();
+        bool flip = rc->platformFeatures().framebufferBottomUp;
+
+        rive::cmd::DeferredSession session(nullptr);
+        rive::cmd::DeferredReplayer replayer;
+        auto green = solidPaint(session, 0xff30c060);
+        auto orange = solidPaint(session, 0xffe08830);
+        auto circle = ovalPath(session, {24, 24, 104, 104});
+        auto dot = ovalPath(session, {8, 8, 40, 40});
+
+        auto recordA = [&]() {
+            Renderer* a = session.beginCanvasContent(canvasA.get(), 0xff103050);
+            a->drawPath(circle.get(), green.get());
+            session.endCanvasContent(canvasA.get());
+        };
+        auto recordB = [&]() {
+            // B composites A, then draws its own dot on top.
+            Renderer* b = session.beginCanvasContent(canvasB.get(), 0xff501030);
+            drawCanvasImage(b, canvasA.get(), 0, 0, flip);
+            b->drawPath(dot.get(), orange.get());
+            session.endCanvasContent(canvasB.get());
+        };
+        if (m_reversed)
+        {
+            recordB();
+            recordA();
+        }
+        else
+        {
+            recordA();
+            recordB();
+        }
+        auto screen = session.makeScreenRenderer();
+        drawCanvasImage(screen.get(), canvasA.get(), 0, 64, flip);
+        drawCanvasImage(screen.get(), canvasB.get(), 128, 64, flip);
+
+        replayFrameThroughGM(session, replayer, rc, mainDesc);
+    }
+
+private:
+    bool m_reversed;
+};
+
+GMREGISTER(canvas_dag_chain, return new CanvasDagChainGM(false))
+GMREGISTER(canvas_dag_chain_reversed, return new CanvasDagChainGM(true))
+
+// A samples B while B samples A. The demoted back edge samples the previous
+// frame by contract: frame two must show frame one's content crossed over,
+// deterministic because frame one seeded both canvases.
+class CanvasDagCycleGM : public GM
+{
+public:
+    CanvasDagCycleGM() : GM(256, 256) {}
+
+    ColorInt clearColor() const override { return 0xff202028; }
+
+    void onDraw(Renderer*) override
+    {
+        auto rc = TestingWindow::Get()->renderContext();
+        if (!rc)
+        {
+            return;
+        }
+        auto canvasA = rc->makeRenderCanvas(128, 128);
+        auto canvasB = rc->makeRenderCanvas(128, 128);
+        if (!canvasA || !canvasB)
+        {
+            return;
+        }
+        auto mainDesc = rc->frameDescriptor();
+        bool flip = rc->platformFeatures().framebufferBottomUp;
+
+        rive::cmd::DeferredSession session(nullptr);
+        rive::cmd::DeferredReplayer replayer;
+
+        // Frame one: seed A green, B orange, no cross sampling.
+        {
+            auto green = solidPaint(session, 0xff30c060);
+            auto orange = solidPaint(session, 0xffe08830);
+            auto circle = ovalPath(session, {24, 24, 104, 104});
+            Renderer* a = session.beginCanvasContent(canvasA.get(), 0xff103050);
+            a->drawPath(circle.get(), green.get());
+            session.endCanvasContent(canvasA.get());
+            Renderer* b = session.beginCanvasContent(canvasB.get(), 0xff501030);
+            b->drawPath(circle.get(), orange.get());
+            session.endCanvasContent(canvasB.get());
+            replayFrameThroughGM(session, replayer, rc, mainDesc);
+        }
+
+        // Frame two: each canvas samples the other shrunken, then the screen
+        // shows both. The back edge sees frame one's pixels.
+        {
+            auto white = solidPaint(session, 0xffffffff);
+            auto dot = ovalPath(session, {4, 4, 24, 24});
+            Renderer* a = session.beginCanvasContent(canvasA.get(), 0xff103050);
+            a->save();
+            a->scale(0.5f, 0.5f);
+            drawCanvasImage(a, canvasB.get(), 0, 0, flip);
+            a->restore();
+            a->drawPath(dot.get(), white.get());
+            session.endCanvasContent(canvasA.get());
+
+            Renderer* b = session.beginCanvasContent(canvasB.get(), 0xff501030);
+            b->save();
+            b->scale(0.5f, 0.5f);
+            drawCanvasImage(b, canvasA.get(), 0, 0, flip);
+            b->restore();
+            b->drawPath(dot.get(), white.get());
+            session.endCanvasContent(canvasB.get());
+
+            auto screen = session.makeScreenRenderer();
+            drawCanvasImage(screen.get(), canvasA.get(), 0, 64, flip);
+            drawCanvasImage(screen.get(), canvasB.get(), 128, 64, flip);
+            replayFrameThroughGM(session, replayer, rc, mainDesc);
+        }
+    }
+};
+
+GMREGISTER(canvas_dag_cycle, return new CanvasDagCycleGM())
+
+#else
+
+// Canvas or scripting disabled: nothing to register.
+
+#endif
diff --git a/tests/gm/render_deferred_2d.cpp b/tests/gm/render_deferred_2d.cpp
new file mode 100644
index 0000000..89cc052
--- /dev/null
+++ b/tests/gm/render_deferred_2d.cpp
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2026 Rive
+ *
+ * Draws the same scene immediately and via a DeferredFactory recording
+ * replayed against the real factory and renderer. The goldens must be byte
+ * identical.
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+#include "rive/renderer/cmd/deferred_render_factory.hpp"
+#include "rive/renderer/cmd/render_replay.hpp"
+#include "rive/math/raw_path.hpp"
+#include "rive/math/mat2d.hpp"
+#include "rive/shapes/paint/image_sampler.hpp"
+#include "assets/batdude.png.hpp"
+
+using namespace rivegm;
+using namespace rive;
+
+static RawPath kShape()
+{
+    RawPath p;
+    p.move({40, 40});
+    p.line({160, 70});
+    p.line({120, 110});
+    p.line({200, 200});
+    p.line({120, 160});
+    p.line({60, 210});
+    p.close();
+    return p;
+}
+
+// Fill and stroke so all paint properties get recorded.
+static void drawScene(Factory* factory, Renderer* renderer)
+{
+    RawPath shape = kShape();
+    auto path = factory->makeRenderPath(shape, FillRule::nonZero);
+
+    auto fill = factory->makeRenderPaint();
+    fill->style(RenderPaintStyle::fill);
+    fill->color(0xFFFFA030);
+    renderer->drawPath(path.get(), fill.get());
+
+    auto stroke = factory->makeRenderPaint();
+    stroke->style(RenderPaintStyle::stroke);
+    stroke->color(0xFF3050FF);
+    stroke->thickness(10);
+    stroke->join(StrokeJoin::round);
+    stroke->cap(StrokeCap::round);
+    renderer->drawPath(path.get(), stroke.get());
+
+    // Exercises gradient shaders.
+    RawPath box;
+    box.move({30, 30});
+    box.line({226, 30});
+    box.line({226, 90});
+    box.line({30, 90});
+    box.close();
+    auto boxPath = factory->makeRenderPath(box, FillRule::nonZero);
+    const ColorInt colors[] = {0xFF00E0A0, 0xFFE000A0};
+    const float stops[] = {0.0f, 1.0f};
+    auto grad = factory->makeLinearGradient(30, 30, 226, 90, colors, stops, 2);
+    auto gradPaint = factory->makeRenderPaint();
+    gradPaint->style(RenderPaintStyle::fill);
+    gradPaint->shader(grad);
+    renderer->drawPath(boxPath.get(), gradPaint.get());
+
+    // Exercises paths built verb by verb.
+    auto tri = factory->makeEmptyRenderPath();
+    tri->fillRule(FillRule::nonZero);
+    tri->moveTo(20, 195);
+    tri->lineTo(80, 195);
+    tri->lineTo(50, 150);
+    tri->close();
+    auto triPaint = factory->makeRenderPaint();
+    triPaint->style(RenderPaintStyle::fill);
+    triPaint->color(0xFFFFFFFF);
+    renderer->drawPath(tri.get(), triPaint.get());
+
+    // Exercises image decode and draw.
+    auto img = factory->decodeImage(assets::batdude_png());
+    if (img)
+    {
+        renderer->save();
+        renderer->transform(Mat2D(0.12f, 0, 0, 0.12f, 150, 110));
+        renderer->drawImage(img.get(),
+                            ImageSampler::LinearClamp(),
+                            BlendMode::srcOver,
+                            1.0f);
+        renderer->restore();
+    }
+}
+
+class RenderDeferred2DGM : public GM
+{
+public:
+    RenderDeferred2DGM(bool deferred) : GM(256, 256), m_deferred(deferred) {}
+
+    ColorInt clearColor() const override { return 0xff202028; }
+
+    void onDraw(rive::Renderer* renderer) override
+    {
+        Factory* factory = TestingWindow::Get()->factory();
+        if (!factory)
+        {
+            return;
+        }
+
+        if (m_deferred)
+        {
+            cmd::DeferredFactory df;
+            auto dr = df.makeRenderer();
+            drawScene(&df, dr.get());
+            cmd::replayRenderCommands(factory, renderer, df.commandBuffer());
+        }
+        else
+        {
+            drawScene(factory, renderer);
+        }
+    }
+
+private:
+    bool m_deferred;
+};
+
+GMREGISTER(render_deferred_2d_immediate, return new RenderDeferred2DGM(false))
+GMREGISTER(render_deferred_2d, return new RenderDeferred2DGM(true))
diff --git a/tests/gm/serialized_replay_2d.cpp b/tests/gm/serialized_replay_2d.cpp
new file mode 100644
index 0000000..a30d10f
--- /dev/null
+++ b/tests/gm/serialized_replay_2d.cpp
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2026 Rive
+ *
+ * Draws the same shape immediately and via a SerializingFactory recording
+ * replayed against the real factory and renderer. The goldens must be byte
+ * identical since the recorded stream is the deferral contract.
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+#include "utils/serializing_factory.hpp"
+#include "utils/serialized_replay.hpp"
+#include "rive/math/raw_path.hpp"
+
+using namespace rivegm;
+using namespace rive;
+
+static RawPath kShape()
+{
+    // Nonconvex polygon so winding is exercised.
+    RawPath p;
+    p.move({40, 40});
+    p.line({160, 70});
+    p.line({120, 110});
+    p.line({200, 200});
+    p.line({120, 160});
+    p.line({60, 210});
+    p.close();
+    return p;
+}
+
+class SerializedReplay2DGM : public GM
+{
+public:
+    SerializedReplay2DGM(bool replay) : GM(256, 256), m_replay(replay) {}
+
+    ColorInt clearColor() const override { return 0xff202028; }
+
+    void onDraw(rive::Renderer* renderer) override
+    {
+        Factory* factory = TestingWindow::Get()->factory();
+        if (!factory)
+            return;
+
+        RawPath shape = kShape();
+
+        if (m_replay)
+        {
+            SerializingFactory sf;
+            auto recorder = sf.makeRenderer();
+            auto path = sf.makeRenderPath(shape, FillRule::nonZero);
+            auto paint = sf.makeRenderPaint();
+            paint->color(0xFFFFA030);
+            paint->style(RenderPaintStyle::fill);
+            recorder->drawPath(path.get(), paint.get());
+
+            replaySerializedCommands(sf.bytes(), factory, renderer);
+        }
+        else
+        {
+            auto path = factory->makeRenderPath(shape, FillRule::nonZero);
+            auto paint = factory->makeRenderPaint();
+            paint->color(0xFFFFA030);
+            paint->style(RenderPaintStyle::fill);
+            renderer->drawPath(path.get(), paint.get());
+        }
+    }
+
+private:
+    bool m_replay;
+};
+
+GMREGISTER(serialized_replay_2d_immediate,
+           return new SerializedReplay2DGM(false))
+GMREGISTER(serialized_replay_2d, return new SerializedReplay2DGM(true))
diff --git a/tests/goldens/goldens.cpp b/tests/goldens/goldens.cpp
index ac2cb54..1af0eca 100644
--- a/tests/goldens/goldens.cpp
+++ b/tests/goldens/goldens.cpp
@@ -5,18 +5,10 @@
 // Don't compile this file as part of the "tests" project.
 #ifndef TESTING
 
-#include "goldens_arguments.hpp"
-#include "common/test_harness.hpp"
+#include "goldens_shared.hpp"
 #include "common/tcp_client.hpp"
 #include "common/rive_mgr.hpp"
-#include "common/testing_window.hpp"
 #include "common/write_png_file.hpp"
-#include "rive/artboard.hpp"
-#include "rive/renderer.hpp"
-#include "rive/file.hpp"
-#include "rive/refcnt.hpp"
-#include "rive/animation/state_machine_instance.hpp"
-#include "rive/static_scene.hpp"
 #include <filesystem>
 #include <fstream>
 #include <iostream>
@@ -34,74 +26,60 @@
 
 GoldensArguments s_args;
 
-class RIVLoader
+// RIVE_GOLDENS_ADVANCE=N advances N frames at sixty fps before rendering.
+static void advanceScene(rive::Scene* scene)
 {
-public:
-    RIVLoader(const std::vector<uint8_t>& rivBytes,
-              const char* artboardName,
-              const char* stateMachineName)
+    const char* a = goldens_getenv("RIVE_GOLDENS_ADVANCE");
+    int frames = a ? atoi(a) : 0;
+    if (frames <= 0)
     {
-        m_file = rive::File::import(rivBytes, TestingWindow::Get()->factory());
-        if (m_file == nullptr)
-        {
-            throw "Bad riv file";
-        }
-        if (artboardName != nullptr && artboardName[0] != '\0')
-        {
-            m_artboard = m_file->artboardNamed(artboardName);
-        }
-        else
-        {
-            m_artboard = m_file->artboardDefault();
-        }
-        if (m_artboard == nullptr)
-        {
-            throw "Can't load artboard";
-        }
-
-        // Bind the default view model instance
-        m_viewModelInstance = m_file->createViewModelInstance(m_artboard.get());
-        m_artboard->bindViewModelInstance(m_viewModelInstance);
-
-        if (stateMachineName != nullptr && stateMachineName[0] != '\0')
-        {
-            m_scene = m_artboard->stateMachineNamed(stateMachineName);
-        }
-        else
-        {
-            m_scene = m_artboard->defaultStateMachine();
-        }
-
-        if (m_scene == nullptr)
-        {
-            // This is a riv without any state machines. Just draw the artboard.
-            m_scene = std::make_unique<rive::StaticScene>(m_artboard.get());
-        }
-
-        if (m_scene != nullptr && m_viewModelInstance != nullptr)
-        {
-            m_scene->bindViewModelInstance(m_viewModelInstance);
-        }
+        scene->advanceAndApply(0);
+        return;
     }
+    for (int i = 0; i < frames; ++i)
+        scene->advanceAndApply(1.0f / 60.0f);
+}
 
-    rive::Scene* stateMachine() const { return m_scene.get(); }
-
-private:
-    rive::rcp<rive::File> m_file;
-    std::unique_ptr<rive::ArtboardInstance> m_artboard;
-    std::unique_ptr<rive::Scene> m_scene;
-    rive::rcp<rive::ViewModelInstance> m_viewModelInstance;
-};
-
-static bool render_and_dump_png(int cellSize,
-                                const char* rivName,
-                                const std::vector<uint8_t>& rivBytes,
-                                const char* artboardName,
-                                const char* stateMachineName)
+void dumpPixelsAsPng(const char* rivName,
+                     int windowWidth,
+                     int windowHeight,
+                     std::vector<uint8_t> pixels)
 {
-    int windowWidth = cellSize * s_args.cols();
-    int windowHeight = cellSize * s_args.rows();
-    TestingWindow::Get()->resize(windowWidth, windowHeight);
+    assert(pixels.size() ==
+           static_cast<size_t>(windowHeight) * windowWidth * 4);
+    std::ostringstream imageName;
+    imageName
+        << std::filesystem::path(rivName).filename().stem().generic_string();
+    if (s_args.rows() != 1 || s_args.cols() != 1)
+    {
+        imageName << '.' << s_args.cols() << 'x' << s_args.rows() << '.';
+    }
+    TestHarness::Instance().savePNG({
+        .name = imageName.str(),
+        .width = static_cast<uint32_t>(windowWidth),
+        .height = static_cast<uint32_t>(windowHeight),
+        .pixels = std::move(pixels),
+    });
+    if (s_args.verbose())
+    {
+        printf("[goldens] Sent %s\n",
+               std::filesystem::path(imageName.str())
+                   .replace_extension("png")
+                   .generic_string()
+                   .c_str());
+    }
+}
+
+static bool render_and_dump_png(
+    int cellSize,
+    const char* rivName,
+    rive::Scene* scene,
+    rive::Artboard* artboard = nullptr,
+    rive::cmd::DeferredSession* deferredSession = nullptr)
+{
+    // onceAfterGM can tear down the window between rivs, size every run.
+    TestingWindow::Get()->resize(cellSize * s_args.cols(),
+                                 cellSize * s_args.rows());
 
     if (s_args.verbose())
     {
@@ -109,19 +87,74 @@
     }
     try
     {
-        RIVLoader riv(rivBytes, artboardName, stateMachineName);
-        rive::Scene* stateMachine = riv.stateMachine();
-
         const int frames = s_args.cols() * s_args.rows();
-        const double duration = stateMachine->durationSeconds();
+        const double duration = scene->durationSeconds();
         const double frameDuration = duration / frames;
         const rive::AABB cellBounds = rive::AABB(0, 0, cellSize, cellSize);
 
-        // Render the stateMachine in a grid.
+#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS)
+        // Deferred mode records the screen and Ore through the session, then
+        // replays synchronously per grid cell. The cadence mirrors the
+        // immediate path below so the output must be byte identical.
+        if (deferredSession != nullptr && artboard != nullptr)
+        {
+            advanceScene(scene);
+            rive::cmd::DeferredReplayer replayer;
+            for (int y = 0; y < s_args.rows(); ++y)
+            {
+                for (int x = 0; x < s_args.cols(); ++x)
+                {
+                    bool first = (x | y) == 0;
+                    if (!first)
+                    {
+                        scene->advanceAndApply(frameDuration);
+                    }
+                    deferredSession->recordOreReplayMarker();
+
+                    auto screenRec = deferredSession->makeScreenRenderer();
+                    screenRec->save();
+                    screenRec->translate(x * cellSize, y * cellSize);
+                    screenRec->align(rive::Fit::cover,
+                                     rive::Alignment::center,
+                                     cellBounds,
+                                     scene->bounds());
+                    artboard->drawInternal(screenRec.get());
+                    screenRec->restore();
+
+                    // Snapshot replay is the same path a threaded consumer
+                    // takes.
+                    rive::cmd::DeferredFrame frame =
+                        rive::cmd::snapshotFrame(*deferredSession);
+                    deferredSession->resetFrame();
+                    GoldensFrameSink sink(first);
+                    replayer.replayFrame(frame, sink);
+
+                    bool last =
+                        y == s_args.rows() - 1 && x == s_args.cols() - 1;
+                    if (!last)
+                    {
+                        TestingWindow::Get()->endFrame();
+                    }
+                }
+            }
+
+            int windowWidth = s_args.cols() * cellSize;
+            int windowHeight = s_args.rows() * cellSize;
+            std::vector<uint8_t> pixels;
+            TestingWindow::Get()->endFrame(&pixels);
+            dumpPixelsAsPng(rivName,
+                            windowWidth,
+                            windowHeight,
+                            std::move(pixels));
+            return true;
+        }
+#endif
+
+        // Render the scene in a grid.
+        advanceScene(scene);
         auto renderer =
             TestingWindow::Get()->beginFrame({.clearColor = 0xffffffff});
         renderer->save();
-        stateMachine->advanceAndApply(0);
         for (int y = 0; y < s_args.rows(); ++y)
         {
             for (int x = 0; x < s_args.cols(); ++x)
@@ -129,8 +162,8 @@
                 if ((x | y) != 0)
                 {
                     TestingWindow::Get()->endFrame();
+                    scene->advanceAndApply(frameDuration);
                     TestingWindow::Get()->beginFrame({.doClear = false});
-                    stateMachine->advanceAndApply(frameDuration);
                 }
 
                 renderer->save();
@@ -139,8 +172,16 @@
                 renderer->align(rive::Fit::cover,
                                 rive::Alignment::center,
                                 cellBounds,
-                                stateMachine->bounds());
-                stateMachine->draw(renderer.get());
+                                scene->bounds());
+
+                if (artboard != nullptr)
+                {
+                    artboard->drawInternal(renderer.get());
+                }
+                else
+                {
+                    scene->draw(renderer.get());
+                }
 
                 renderer->restore();
             }
@@ -148,35 +189,11 @@
         renderer->restore();
 
         // Save the png.
+        int windowWidth = s_args.cols() * cellSize;
+        int windowHeight = s_args.rows() * cellSize;
         std::vector<uint8_t> pixels;
         TestingWindow::Get()->endFrame(&pixels);
-        assert(pixels.size() == windowHeight * windowWidth * 4);
-
-        std::ostringstream imageName;
-        imageName << std::filesystem::path(rivName)
-                         .filename()
-                         .stem()
-                         .generic_string();
-        if (s_args.rows() != 1 || s_args.cols() != 1)
-        {
-            imageName << '.' << s_args.cols() << 'x' << s_args.rows() << '.';
-        }
-
-        TestHarness::Instance().savePNG({
-            .name = imageName.str(),
-            .width = static_cast<uint32_t>(windowWidth),
-            .height = static_cast<uint32_t>(windowHeight),
-            .pixels = std::move(pixels),
-        });
-
-        if (s_args.verbose())
-        {
-            printf("[goldens] Sent %s\n",
-                   std::filesystem::path(imageName.str())
-                       .replace_extension("png")
-                       .generic_string()
-                       .c_str());
-        }
+        dumpPixelsAsPng(rivName, windowWidth, windowHeight, std::move(pixels));
 
         if (s_args.interactive())
         {
@@ -216,11 +233,6 @@
         fprintf(stderr, "error rendering %s\n", rivName);
         abort();
     }
-
-    // Allow the testing window to do any cleanup it might want to do between
-    // GMs
-    TestingWindow::Get()->onceAfterGM();
-
     return true;
 }
 
@@ -232,12 +244,36 @@
         throw "Bad file";
     }
 
-    return render_and_dump_png(
-        cellSize,
-        file.c_str(),
-        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
-        s_args.artboard().c_str(),
-        s_args.stateMachine().c_str());
+    std::vector<uint8_t> bytes(std::istreambuf_iterator<char>(stream), {});
+#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS)
+    if (const char* n = goldens_getenv("RIVE_GOLDENS_BENCH"))
+    {
+        int iters = atoi(n);
+        if (iters <= 0)
+            iters = 200;
+        run_benchmark(bytes,
+                      s_args.artboard().c_str(),
+                      s_args.stateMachine().c_str(),
+                      iters);
+        return true;
+    }
+#endif
+
+    bool ok;
+    {
+        RIVLoader riv(bytes,
+                      s_args.artboard().c_str(),
+                      s_args.stateMachine().c_str());
+        ok = render_and_dump_png(cellSize,
+                                 file.c_str(),
+                                 riv.stateMachine(),
+                                 riv.artboard(),
+                                 riv.deferredSession());
+    }
+    // Between-GM cleanup can tear the device down, so the loader and its
+    // recorded resources must already be gone.
+    TestingWindow::Get()->onceAfterGM();
+    return ok;
 }
 
 static bool is_riv_file(const std::filesystem::path& file)
@@ -311,6 +347,9 @@
 
         int cellSize =
             kWindowTargetSize / std::max(s_args.cols(), s_args.rows());
+        int windowWidth = cellSize * s_args.cols();
+        int windowHeight = cellSize * s_args.rows();
+        TestingWindow::Get()->resize(windowWidth, windowHeight);
 
         // First check if the --src argument is a TCP server instead of a file.
         if (TestHarness::Instance().hasTCPConnection())
@@ -320,14 +359,22 @@
             std::vector<uint8_t> rivBytes;
             while (TestHarness::Instance().fetchRivFile(rivName, rivBytes))
             {
-                if (!render_and_dump_png(cellSize,
-                                         rivName.c_str(),
-                                         rivBytes,
-                                         nullptr /*default artboard*/,
-                                         nullptr /*default state machine*/))
                 {
-                    return 0;
+                    RIVLoader riv(rivBytes,
+                                  nullptr /*default artboard*/,
+                                  nullptr /*default state machine*/);
+                    if (!render_and_dump_png(cellSize,
+                                             rivName.c_str(),
+                                             riv.stateMachine(),
+                                             riv.artboard(),
+                                             riv.deferredSession()))
+                    {
+                        return 0;
+                    }
                 }
+                // Between-GM cleanup can tear the device down, so the loader
+                // and its recorded resources must already be gone.
+                TestingWindow::Get()->onceAfterGM();
             }
         }
         else
diff --git a/tests/goldens/goldens_arguments.hpp b/tests/goldens/goldens_arguments.hpp
index 0d09a63..6bf1f7d 100644
--- a/tests/goldens/goldens_arguments.hpp
+++ b/tests/goldens/goldens_arguments.hpp
@@ -95,6 +95,12 @@
                                    "only_ubershaders",
                                    "Use only ubershaders (where supported)",
                                    {'u', "only_ubershaders"});
+        args::Flag deferred(
+            optional,
+            "deferred",
+            "record through a deferred session and replay synchronously; "
+            "output must be identical to immediate mode",
+            {"deferred"});
 
         args::CompletionFlag completion(*m_parser, {"complete"});
         try
@@ -143,6 +149,7 @@
         m_cols = std::max(args::get(cols), 1);
         m_pngThreads = std::max(args::get(pngThreads), 1);
         m_onlyUbershaders = args::get(onlyUbershaders);
+        m_deferred = args::get(deferred);
     }
 
     const std::string& testHarness() const { return m_testHarness; }
@@ -159,6 +166,7 @@
     int cols() const { return m_cols; }
     int pngThreads() const { return m_pngThreads; }
     bool onlyUbershaders() const { return m_onlyUbershaders; }
+    bool deferred() const { return m_deferred; }
 
 private:
     std::unique_ptr<args::ArgumentParser> m_parser;
@@ -178,5 +186,6 @@
     int m_cols;
     int m_pngThreads;
     bool m_onlyUbershaders;
+    bool m_deferred;
 };
 #endif
diff --git a/tests/goldens/goldens_bench.cpp b/tests/goldens/goldens_bench.cpp
new file mode 100644
index 0000000..d08c87e
--- /dev/null
+++ b/tests/goldens/goldens_bench.cpp
@@ -0,0 +1,718 @@
+/*
+ * Copyright 2022 Rive
+ */
+
+// Env gated diagnostics and the RIVE_GOLDENS_BENCH benchmark, split out of
+// goldens.cpp.
+
+// Don't compile this file as part of the "tests" project.
+#ifndef TESTING
+
+#include "goldens_shared.hpp"
+
+#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS)
+
+#include <algorithm>
+#include <chrono>
+#include <cstdio>
+#include <unordered_map>
+
+// Tallies per resource mutations and draws in one recorded 2D frame to spot
+// redundant rebuilds.
+static void analyze_frame_redundancy(const rive::cmd::RenderCommandBuffer& cmd)
+{
+    using namespace rive::cmd;
+    RenderCommandReader r(cmd.commandBytes(), cmd.blobBytes());
+    std::unordered_map<RenderHandle, int> rewinds, addRaw, draws, paintSets;
+    std::unordered_map<RenderHandle, uint32_t> lastColor;
+    int colorSets = 0, colorSameValueRepeat = 0;
+    size_t geomBytes = 0;
+    uint8_t type;
+    while (r.next(type))
+    {
+        switch (static_cast<RenderCmd>(type))
+        {
+            case RenderCmd::pathRewind:
+                rewinds[r.read<ResIdPOD>().id]++;
+                break;
+            case RenderCmd::pathFillRule:
+                r.read<PathFillRulePOD>();
+                break;
+            case RenderCmd::pathAddRawPath:
+            {
+                auto c = r.read<PathRawPOD>();
+                addRaw[c.path]++;
+                geomBytes += c.verbCount * sizeof(rive::PathVerb) +
+                             c.pointCount * sizeof(rive::Vec2D);
+                break;
+            }
+            case RenderCmd::pathAddRenderPath:
+                r.read<PathAddPathPOD>();
+                break;
+            case RenderCmd::paintStyle:
+            case RenderCmd::paintJoin:
+            case RenderCmd::paintCap:
+            case RenderCmd::paintBlendMode:
+                paintSets[r.read<PaintU8POD>().paint]++;
+                break;
+            case RenderCmd::paintColor:
+            {
+                auto c = r.read<PaintColorPOD>();
+                paintSets[c.paint]++;
+                colorSets++;
+                auto it = lastColor.find(c.paint);
+                if (it != lastColor.end() && it->second == c.color)
+                    colorSameValueRepeat++;
+                lastColor[c.paint] = c.color;
+                break;
+            }
+            case RenderCmd::paintThickness:
+            case RenderCmd::paintFeather:
+                paintSets[r.read<PaintFloatPOD>().paint]++;
+                break;
+            case RenderCmd::paintShader:
+                paintSets[r.read<PaintShaderPOD>().paint]++;
+                break;
+            case RenderCmd::paintInvalidateStroke:
+                r.read<ResIdPOD>();
+                break;
+            case RenderCmd::save:
+            case RenderCmd::restore:
+            case RenderCmd::makeEmptyPath:
+            case RenderCmd::makePaint:
+                break;
+            case RenderCmd::transform:
+                r.read<TransformPOD>();
+                break;
+            case RenderCmd::drawPath:
+                draws[r.read<DrawPathPOD>().path]++;
+                break;
+            case RenderCmd::clipPath:
+                r.read<ClipPathPOD>();
+                break;
+            case RenderCmd::resourceNewVersion:
+                r.read<ResourceVersionPOD>();
+                break;
+            case RenderCmd::drawImage:
+                r.read<DrawImagePOD>();
+                break;
+            case RenderCmd::drawImageMesh:
+                r.read<DrawImageMeshPOD>();
+                break;
+            case RenderCmd::modulateOpacity:
+                r.read<OpacityPOD>();
+                break;
+            case RenderCmd::canvasContentBegin:
+                r.read<CanvasContentPOD>();
+                break;
+            case RenderCmd::canvasContentEnd:
+                r.read<ResIdPOD>();
+                break;
+            case RenderCmd::makePath:
+                r.read<MakePathPOD>();
+                break;
+            case RenderCmd::makeLinearGradient:
+                r.read<LinearGradientPOD>();
+                break;
+            case RenderCmd::makeRadialGradient:
+                r.read<RadialGradientPOD>();
+                break;
+            case RenderCmd::decodeImage:
+                r.read<DecodeImagePOD>();
+                break;
+            case RenderCmd::makeBuffer:
+                r.read<MakeBufferPOD>();
+                break;
+            case RenderCmd::bufferData:
+                r.read<BufferDataPOD>();
+                break;
+            case RenderCmd::destroyResource:
+                r.read<DestroyResourcePOD>();
+                break;
+        }
+    }
+    auto sum = [](const std::unordered_map<RenderHandle, int>& m) {
+        int t = 0;
+        for (auto& kv : m)
+            t += kv.second;
+        return t;
+    };
+    auto multi = [](const std::unordered_map<RenderHandle, int>& m) {
+        int t = 0, mx = 0;
+        for (auto& kv : m)
+        {
+            if (kv.second > 1)
+                t++;
+            mx = std::max(mx, kv.second);
+        }
+        return std::pair<int, int>(t, mx);
+    };
+    int totalRewind = sum(rewinds), totalAdd = sum(addRaw),
+        totalDraw = sum(draws), totalPaint = sum(paintSets);
+    auto rw = multi(rewinds);
+    auto ad = multi(addRaw);
+    auto pt = multi(paintSets);
+
+    printf("\n-- frame redundancy analysis (one clean frame) --\n");
+    printf("  paths: %zu distinct rewound, %d total rewinds  "
+           "(%d rewound >1x, max %dx)\n",
+           rewinds.size(),
+           totalRewind,
+           rw.first,
+           rw.second);
+    printf("  paths: %zu distinct addRawPath, %d total adds   "
+           "(%d added >1x, max %dx), geom %.1f KB\n",
+           addRaw.size(),
+           totalAdd,
+           ad.first,
+           ad.second,
+           geomBytes / 1024.0);
+    printf("  paints: %zu distinct, %d total property sets   "
+           "(%d set >1x, max %dx)\n",
+           paintSets.size(),
+           totalPaint,
+           pt.first,
+           pt.second);
+    printf("  paint color sets: %d total, %d set to the SAME value again "
+           "(redundant)\n",
+           colorSets,
+           colorSameValueRepeat);
+    printf("  draws: %zu distinct paths drawn, %d total drawPath\n",
+           draws.size(),
+           totalDraw);
+    if (rewinds.size() > 0)
+        printf("  => rebuild ratio: %.2f rewinds/path, %.2f adds/path "
+               "(1.0 = each built once; >1 = redundant rebuilds)\n",
+               double(totalRewind) / rewinds.size(),
+               addRaw.empty() ? 0.0 : double(totalAdd) / addRaw.size());
+}
+
+// Counts drawPath commands that resolve against the resident table versus ones
+// skipped, to tell missing resources apart from other replay bugs.
+static void diagnose_replay_coverage(const rive::cmd::RenderCommandBuffer& cmd,
+                                     const rive::cmd::ResourceTable& t)
+{
+    using namespace rive::cmd;
+    RenderCommandReader r(cmd.commandBytes(), cmd.blobBytes());
+    int total = 0, resolved = 0, pNull = 0, pOOR = 0, ptNull = 0, ptOOR = 0;
+    RenderHandle maxPath = 0, maxPaint = 0;
+    uint8_t type;
+    while (r.next(type))
+    {
+        switch (static_cast<RenderCmd>(type))
+        {
+            case RenderCmd::drawPath:
+            {
+                auto c = r.read<DrawPathPOD>();
+                total++;
+                maxPath = std::max(maxPath, c.path);
+                maxPaint = std::max(maxPaint, c.paint);
+                bool pOk = t.paths.get(c.path) != nullptr;
+                bool ptOk = t.paints.get(c.paint) != nullptr;
+                if (c.path >= t.paths.objects.size())
+                    pOOR++;
+                else if (!pOk)
+                    pNull++;
+                if (c.paint >= t.paints.objects.size())
+                    ptOOR++;
+                else if (!ptOk)
+                    ptNull++;
+                if (pOk && ptOk)
+                    resolved++;
+                break;
+            }
+            case RenderCmd::pathRewind:
+            case RenderCmd::clipPath:
+            case RenderCmd::paintInvalidateStroke:
+            case RenderCmd::canvasContentEnd:
+                r.read<ResIdPOD>();
+                break;
+            case RenderCmd::pathFillRule:
+                r.read<PathFillRulePOD>();
+                break;
+            case RenderCmd::pathAddRawPath:
+                r.read<PathRawPOD>();
+                break;
+            case RenderCmd::pathAddRenderPath:
+                r.read<PathAddPathPOD>();
+                break;
+            case RenderCmd::paintStyle:
+            case RenderCmd::paintJoin:
+            case RenderCmd::paintCap:
+            case RenderCmd::paintBlendMode:
+                r.read<PaintU8POD>();
+                break;
+            case RenderCmd::paintColor:
+                r.read<PaintColorPOD>();
+                break;
+            case RenderCmd::paintThickness:
+            case RenderCmd::paintFeather:
+                r.read<PaintFloatPOD>();
+                break;
+            case RenderCmd::paintShader:
+                r.read<PaintShaderPOD>();
+                break;
+            case RenderCmd::transform:
+                r.read<TransformPOD>();
+                break;
+            case RenderCmd::drawImage:
+                r.read<DrawImagePOD>();
+                break;
+            case RenderCmd::drawImageMesh:
+                r.read<DrawImageMeshPOD>();
+                break;
+            case RenderCmd::modulateOpacity:
+                r.read<OpacityPOD>();
+                break;
+            case RenderCmd::canvasContentBegin:
+                r.read<CanvasContentPOD>();
+                break;
+            case RenderCmd::makePath:
+                r.read<MakePathPOD>();
+                break;
+            case RenderCmd::makeLinearGradient:
+                r.read<LinearGradientPOD>();
+                break;
+            case RenderCmd::makeRadialGradient:
+                r.read<RadialGradientPOD>();
+                break;
+            case RenderCmd::decodeImage:
+                r.read<DecodeImagePOD>();
+                break;
+            case RenderCmd::makeBuffer:
+                r.read<MakeBufferPOD>();
+                break;
+            case RenderCmd::bufferData:
+                r.read<BufferDataPOD>();
+                break;
+            default:
+                break; // no payload
+        }
+    }
+    printf(
+        "\n-- replay coverage diagnosis (clean frame vs resident table) --\n");
+    printf("  table: %zu paths, %zu paints\n",
+           t.paths.objects.size(),
+           t.paints.objects.size());
+    printf("  drawPath: %d total, %d resolved, skipped path[null %d, OOR %d] "
+           "paint[null %d, OOR %d]\n",
+           total,
+           resolved,
+           pNull,
+           pOOR,
+           ptNull,
+           ptOOR);
+    printf("  max referenced: path id %u, paint id %u\n", maxPath, maxPaint);
+}
+
+// Drains one recorded frame through the caller owned replayer. Leaves the
+// screen frame open for the caller to present via endFrame.
+static void replay_deferred_frame(rive::cmd::DeferredReplayer& replayer,
+                                  rive::cmd::DeferredSession* session)
+{
+    GoldensFrameSink sink;
+    replayer.replayFrame(*session, sink);
+}
+
+void run_benchmark(const std::vector<uint8_t>& bytes,
+                   const char* artboardName,
+                   const char* stateMachineName,
+                   int iters)
+{
+    using clock = std::chrono::steady_clock;
+    auto us = [](clock::duration d) {
+        return std::chrono::duration<double, std::micro>(d).count();
+    };
+    auto* win = TestingWindow::Get();
+    const int cellSize = 256;
+    const rive::AABB cell(0, 0, cellSize, cellSize);
+    const float dt = 1.0f / 60.0f;
+    const int kWarmup = 8;
+
+    auto drawInto = [&](rive::Renderer* r, rive::Scene* s, rive::Artboard* a) {
+        r->save();
+        r->align(rive::Fit::cover, rive::Alignment::center, cell, s->bounds());
+        a->drawInternal(r);
+        r->restore();
+    };
+
+    // Immediate: full main thread frame.
+    RIVLoader imm(bytes,
+                  artboardName,
+                  stateMachineName,
+                  RIVLoader::DeferMode::Immediate);
+    auto* immScene = imm.stateMachine();
+    auto* immArt = imm.artboard();
+    immScene->advanceAndApply(0.0f);
+    auto immFrame = [&]() {
+        immScene->advanceAndApply(dt);
+        auto r = win->beginFrame({.clearColor = 0xffffffff});
+        drawInto(r.get(), immScene, immArt);
+        win->endFrame();
+    };
+    for (int i = 0; i < kWarmup; ++i)
+        immFrame();
+    auto t0 = clock::now();
+    for (int i = 0; i < iters; ++i)
+        immFrame();
+    double immUs = us(clock::now() - t0) / iters;
+
+    // Deferred record: no GPU submission.
+    RIVLoader def(bytes,
+                  artboardName,
+                  stateMachineName,
+                  RIVLoader::DeferMode::Deferred);
+    auto* session = def.deferredSession();
+    auto* defScene = def.stateMachine();
+    auto* defArt = def.artboard();
+    defScene->advanceAndApply(0.0f);
+    auto recFrame = [&]() {
+        defScene->advanceAndApply(dt);
+        session->recordOreReplayMarker();
+        auto r = session->makeScreenRenderer();
+        drawInto(r.get(), defScene, defArt);
+    };
+    // The 2D stream accumulates because recFrame never resets. Resources are
+    // created on the first frame only, so later deltas are draws only.
+    auto bytes2D = [&]() -> size_t {
+        return session->commandBuffer().commandBytes().size() +
+               session->commandBuffer().blobBytes().size();
+    };
+    auto streamBytes = [&]() -> size_t {
+        return bytes2D() + session->oreContext().streamBytes().total();
+    };
+    recFrame(); // first frame includes one time resource creation
+    auto coldOre = session->oreContext().streamBytes();
+    size_t cold2D = bytes2D();
+    size_t coldBytes = cold2D + coldOre.total();
+    for (int i = 1; i < kWarmup; ++i)
+        recFrame();
+    size_t before = streamBytes();
+    size_t before2D = bytes2D();
+    auto t1 = clock::now();
+    for (int i = 0; i < iters; ++i)
+        recFrame();
+    double recUs = us(clock::now() - t1) / iters;
+    double perFrameBytes = double(streamBytes() - before) / iters;
+    double perFrame2D = double(bytes2D() - before2D) / iters;
+
+    // RIVE_GOLDENS_ORE_HISTO prints an Ore opcode histogram for one clean
+    // frame to diagnose per frame resource churn.
+    if (goldens_getenv("RIVE_GOLDENS_ORE_HISTO"))
+    {
+        session->resetFrame();
+        recFrame();
+        using rive::ore::cmd::CommandType;
+        static const char* kNames[] = {"beginRenderPass", "setPipeline",
+                                       "setVertexBuffer", "setIndexBuffer",
+                                       "setBindGroup",    "setViewport",
+                                       "setScissorRect",  "setStencilRef",
+                                       "setBlendColor",   "draw",
+                                       "drawIndexed",     "finish",
+                                       "makeBuffer",      "makeTexture",
+                                       "makeSampler",     "makeShaderModule",
+                                       "makeBGLayout",    "makeTextureView",
+                                       "makePipeline",    "makeBindGroup",
+                                       "bufferUpdate",    "textureUpload",
+                                       "destroyResource"};
+        int counts[64] = {};
+        auto& cb = session->oreContext().stream();
+        rive::ore::cmd::OreCommandReader rd(cb.commandBytes(), cb.blobBytes());
+        CommandType t;
+        while (rd.next(t))
+        {
+            uint8_t v = static_cast<uint8_t>(t);
+            if (v < 64)
+            {
+                counts[v]++;
+            }
+            rive::ore::cmd::skipOreCommand(t, rd);
+        }
+        printf("\n-- one steady frame, Ore opcode histogram --\n");
+        for (size_t i = 0; i < sizeof(kNames) / sizeof(kNames[0]); ++i)
+        {
+            if (counts[i] != 0)
+            {
+                printf("    %-16s : %d\n", kNames[i], counts[i]);
+            }
+        }
+    }
+
+    // Deferred replay: one recorded frame replayed repeatedly, cold and
+    // steady, to isolate the amortizable resource creation cost.
+    RIVLoader rep(bytes,
+                  artboardName,
+                  stateMachineName,
+                  RIVLoader::DeferMode::Deferred);
+    auto* repSession = rep.deferredSession();
+    auto* repScene = rep.stateMachine();
+    auto* repArt = rep.artboard();
+    repScene->advanceAndApply(0.0f);
+    for (int i = 0; i < kWarmup; ++i)
+        repScene->advanceAndApply(dt);
+    repSession->recordOreReplayMarker();
+    {
+        auto r = repSession->makeScreenRenderer();
+        drawInto(r.get(), repScene, repArt);
+    }
+
+    const int kReplays = 30;
+    // Cold: a fresh replayer each frame recreates every resource.
+    for (int i = 0; i < 3; ++i)
+    {
+        rive::cmd::DeferredReplayer cold;
+        replay_deferred_frame(cold, repSession);
+        win->endFrame();
+    }
+    auto t2 = clock::now();
+    for (int i = 0; i < kReplays; ++i)
+    {
+        rive::cmd::DeferredReplayer cold;
+        replay_deferred_frame(cold, repSession);
+        win->endFrame();
+    }
+    double coldUs = us(clock::now() - t2) / kReplays;
+
+    // Steady: Ore makes are idempotent so Ore resources stay resident. 2D
+    // makes overwrite rather than skip, so 2D resources are recreated.
+    rive::cmd::DeferredReplayer steady;
+    for (int i = 0; i < 3; ++i)
+    {
+        replay_deferred_frame(steady, repSession);
+        win->endFrame();
+    }
+    auto t3 = clock::now();
+    for (int i = 0; i < kReplays; ++i)
+    {
+        replay_deferred_frame(steady, repSession);
+        win->endFrame();
+    }
+    double steadyUs = us(clock::now() - t3) / kReplays;
+
+    // Phase breakdown for pure 2D scenes. Replay runs on a clean single frame
+    // against a primed resident table so it reflects one real frame.
+    bool pure2D = repSession->oreContext().streamBytes().commands == 0;
+    double mImmAdv = 0, mDefAdv = 0, mImmRen = 0, mDefRec = 0;
+    double immAdv = 0, immCpu = 0, immGpu = 0;
+    double repAdv = 0, repRecDraw = 0, repCpu = 0, repGpu = 0;
+    if (pure2D)
+    {
+        for (int i = 0; i < kReplays + 3; ++i)
+        {
+            auto a = clock::now();
+            immScene->advanceAndApply(dt);
+            rive::Artboard::incFrameId();
+            auto b = clock::now();
+            auto r = win->beginFrame({.clearColor = 0xffffffff});
+            drawInto(r.get(), immScene, immArt);
+            auto c = clock::now();
+            win->endFrame();
+            auto d = clock::now();
+            if (i >= 3)
+            {
+                immAdv += us(b - a);
+                immCpu += us(c - b);
+                immGpu += us(d - c);
+            }
+        }
+        immAdv /= kReplays;
+        immCpu /= kReplays;
+        immGpu /= kReplays;
+
+        // Prime the resident table with one full replay, then measure clean
+        // single frames against it.
+        rive::cmd::ResourceTable t2;
+        rive::cmd::replayRenderCommands(win->factory(),
+                                        nullptr,
+                                        repSession->commandBuffer(),
+                                        t2);
+        for (int i = 0; i < kReplays + 3; ++i)
+        {
+            repSession->resetFrame();
+            auto a = clock::now();
+            repScene->advanceAndApply(dt);
+            rive::Artboard::incFrameId();
+            auto a2 = clock::now();
+            {
+                auto rr = repSession->makeScreenRenderer();
+                drawInto(rr.get(), repScene, repArt);
+            }
+            auto b = clock::now();
+            // Consumer replay against the resident table.
+            auto screen = win->beginFrame({.clearColor = 0xffffffff});
+            rive::cmd::replayRenderCommands(win->factory(),
+                                            screen.get(),
+                                            repSession->commandBuffer(),
+                                            t2);
+            auto c = clock::now();
+            bool last = (i == kReplays + 2);
+            std::vector<uint8_t> px;
+            win->endFrame(last && goldens_getenv("RIVE_GOLDENS_BENCH_DUMP")
+                              ? &px
+                              : nullptr);
+            auto d = clock::now();
+            if (last && goldens_getenv("RIVE_GOLDENS_BENCH_DUMP"))
+                dumpPixelsAsPng("bench_consumer",
+                                win->width(),
+                                win->height(),
+                                std::move(px));
+            if (i >= 3)
+            {
+                repAdv += us(a2 - a);
+                repRecDraw += us(b - a2);
+                repCpu += us(c - b);
+                repGpu += us(d - c);
+            }
+        }
+        analyze_frame_redundancy(repSession->commandBuffer());
+        diagnose_replay_coverage(repSession->commandBuffer(), t2);
+        repAdv /= kReplays;
+        repRecDraw /= kReplays;
+        repCpu /= kReplays;
+        repGpu /= kReplays;
+
+        // Two fresh artboards advanced in lockstep so advance is compared at
+        // the same animation state, isolating the serializer overhead.
+        RIVLoader immM(bytes,
+                       artboardName,
+                       stateMachineName,
+                       RIVLoader::DeferMode::Immediate);
+        RIVLoader defM(bytes,
+                       artboardName,
+                       stateMachineName,
+                       RIVLoader::DeferMode::Deferred);
+        auto* immMs = immM.stateMachine();
+        auto* immMa = immM.artboard();
+        auto* defMs = defM.stateMachine();
+        auto* defMa = defM.artboard();
+        auto* defMsess = defM.deferredSession();
+        immMs->advanceAndApply(0.0f);
+        defMs->advanceAndApply(0.0f);
+        for (int i = 0; i < kReplays + 5; ++i)
+        {
+            defMsess->resetFrame();
+            auto t0 = clock::now();
+            immMs->advanceAndApply(dt);
+            auto t1 = clock::now();
+            defMs->advanceAndApply(dt); // same frame, plus serialize
+            auto t2 = clock::now();
+            rive::Artboard::incFrameId();
+            auto rim = win->beginFrame({.clearColor = 0xffffffff});
+            auto t3 = clock::now();
+            drawInto(rim.get(), immMs, immMa);
+            auto t4 = clock::now();
+            win->endFrame();
+            auto t5 = clock::now();
+            {
+                auto rr = defMsess->makeScreenRenderer();
+                drawInto(rr.get(), defMs, defMa); // records instead of drawing
+            }
+            auto t6 = clock::now();
+            if (i >= 5)
+            {
+                mImmAdv += us(t1 - t0);
+                mDefAdv += us(t2 - t1);
+                mImmRen += us(t4 - t3);
+                mDefRec += us(t6 - t5);
+            }
+        }
+        mImmAdv /= kReplays;
+        mDefAdv /= kReplays;
+        mImmRen /= kReplays;
+        mDefRec /= kReplays;
+    }
+
+    printf("\n=== deferred-rendering benchmark (%d iters @ 60fps) ===\n",
+           iters);
+    printf("scene resolution: %dx%d, 1 cell\n", cellSize, cellSize);
+    printf("\n-- per-frame timing (microseconds) --\n");
+    printf("  immediate  (main thread, record + GPU submit) : %9.1f us\n",
+           immUs);
+    printf("  deferred   RECORD only (main thread)          : %9.1f us  "
+           "(%.2fx immediate)\n",
+           recUs,
+           recUs / immUs);
+    printf("  deferred   REPLAY  cold (recreate every frame): %9.1f us  "
+           "(%.2fx immediate)\n",
+           coldUs,
+           coldUs / immUs);
+    printf("  deferred   REPLAY  steady (Ore resident)      : %9.1f us  "
+           "(%.2fx immediate)  [MEASURED]\n",
+           steadyUs,
+           steadyUs / immUs);
+    printf("    Ore shader/pipeline recompile saved/frame   : %9.1f us\n",
+           coldUs - steadyUs);
+    printf("\n-- serialized stream size --\n");
+    printf("  2D  ordered stream, first frame (creates+draws): %9zu B  "
+           "(%.1f KB)\n",
+           cold2D,
+           cold2D / 1024.0);
+    printf("  Ore ordered stream, first frame (creates+passes): %9zu B  "
+           "(%.1f KB)\n",
+           coldOre.total(),
+           coldOre.total() / 1024.0);
+    printf("  steady per-frame (crosses every frame)        : %9.0f B  "
+           "(%.2f KB)\n",
+           perFrameBytes,
+           perFrameBytes / 1024.0);
+    printf("    2D   draws (steady, creates amortized) : %9.0f B\n",
+           perFrame2D);
+    if (pure2D)
+    {
+        printf("\n-- phase breakdown (pure-2D, single clean frame, us) --\n");
+        printf("  IMMEDIATE (all on the main thread):\n");
+        printf("    advance (anim / IK / skin / databind) : %8.1f us\n",
+               immAdv);
+        printf("    render CPU (issue draw calls)         : %8.1f us\n",
+               immCpu);
+        printf("    flush + present (feed the GPU)        : %8.1f us\n",
+               immGpu);
+        printf("    total                                 : %8.1f us\n",
+               immAdv + immCpu + immGpu);
+        printf("  DEFERRED:\n");
+        printf("    PRODUCER (main): advance (+serialize) : %8.1f us  "
+               "(immediate advance was %.1f)\n",
+               repAdv,
+               immAdv);
+        printf("    PRODUCER (main): record draw commands : %8.1f us  "
+               "(immediate draw-CPU was %.1f)\n",
+               repRecDraw,
+               immCpu);
+        printf("    PRODUCER total (main thread)          : %8.1f us\n",
+               repAdv + repRecDraw);
+        printf("    CONSUMER (render): replay CPU         : %8.1f us  "
+               "(parse + apply + draw calls)\n",
+               repCpu);
+        printf("    CONSUMER (render): flush + present    : %8.1f us\n",
+               repGpu);
+        printf("    consumer total (render thread)        : %8.1f us\n",
+               repCpu + repGpu);
+        printf("  deltas:\n");
+        printf("    GPU feed: replay vs immediate         : %+8.1f us  "
+               "(should be ~0 — identical work)\n",
+               repGpu - immGpu);
+        printf("    parse/apply tax: replayCPU - immCPU   : %+8.1f us\n",
+               repCpu - immCpu);
+        printf("    advance moved off render thread       : %8.1f us\n",
+               immAdv);
+        printf(
+            "\n-- matched-frame serializer cost (same animation state) --\n");
+        printf("    advance: immediate %.1f vs deferred %.1f  "
+               "=> serializer-in-advance %+.1f us\n",
+               mImmAdv,
+               mDefAdv,
+               mDefAdv - mImmAdv);
+        printf("    draws  : immediate issue %.1f vs deferred record %.1f  "
+               "=> %+.1f us\n",
+               mImmRen,
+               mDefRec,
+               mDefRec - mImmRen);
+        printf("    total serializer overhead vs immediate: %+.1f us/frame\n",
+               (mDefAdv - mImmAdv) + (mDefRec - mImmRen));
+    }
+    printf("=======================================================\n\n");
+}
+
+#endif // WITH_RIVE_SCRIPTING && RIVE_CANVAS
+
+#endif // TESTING
diff --git a/tests/goldens/goldens_shared.hpp b/tests/goldens/goldens_shared.hpp
new file mode 100644
index 0000000..7f8e4b8
--- /dev/null
+++ b/tests/goldens/goldens_shared.hpp
@@ -0,0 +1,258 @@
+/*
+ * Copyright 2022 Rive
+ */
+
+// Shared between goldens.cpp and the env gated diagnostics in
+// goldens_bench.cpp.
+
+#pragma once
+
+// Don't compile the goldens tool as part of the "tests" project.
+#ifndef TESTING
+
+#include "goldens_arguments.hpp"
+#include "common/test_harness.hpp"
+#include "common/testing_window.hpp"
+#include "rive/artboard.hpp"
+#include "rive/renderer.hpp"
+#include "rive/file.hpp"
+#include "rive/refcnt.hpp"
+#include "rive/animation/state_machine_instance.hpp"
+#include "rive/static_scene.hpp"
+#ifdef WITH_RIVE_SCRIPTING
+#include "rive/lua/scripting_vm.hpp"
+#include "rive/lua/rive_lua_libs.hpp"
+#endif
+#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS)
+// RIVE_GOLDENS_DEFER_ORE records through a DeferredOreContext and replays on
+// the real context in the same frame, single threaded.
+#include "rive/renderer/render_context.hpp"
+#include "rive/renderer/render_context_impl.hpp"
+#include "rive/renderer/render_canvas.hpp"
+#include "rive/renderer/rive_renderer.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#endif
+#include <cstdlib>
+#include <memory>
+#include <vector>
+
+// Builds without scripting or canvas never include the deferred headers but
+// the accessors below still name the type.
+namespace rive::cmd
+{
+class DeferredSession;
+}
+
+extern GoldensArguments s_args;
+
+// Consoles build without an env API, probes just come back unset there.
+inline const char* goldens_getenv(const char* name)
+{
+#ifdef NO_GETENV
+    return nullptr;
+#else
+    return getenv(name);
+#endif
+}
+
+void dumpPixelsAsPng(const char* rivName,
+                     int windowWidth,
+                     int windowHeight,
+                     std::vector<uint8_t> pixels);
+
+#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS)
+// DeferredFrameSink over TestingWindow. Leaves the screen frame open for the
+// caller to present via endFrame.
+class GoldensFrameSink : public rive::cmd::DeferredFrameSink
+{
+public:
+    GoldensFrameSink(bool doClear = true) :
+        m_rc(TestingWindow::Get()->renderContext()), m_doClear(doClear)
+    {}
+
+    rive::Factory* factory() override
+    {
+        return TestingWindow::Get()->factory();
+    }
+
+    // Goldens render into the one TestingWindow, so there is a single target.
+    rive::Renderer* beginScreenFrame(uint64_t target) override
+    {
+        assert(target == 0);
+        m_screen = TestingWindow::Get()->beginFrame(
+            {.clearColor = 0xffffffff, .doClear = m_doClear});
+        return m_screen.get();
+    }
+    void beginOreFrame() override { TestingWindow::Get()->beginOreFrame(); }
+    void endOreFrame() override { TestingWindow::Get()->endOreFrame(); }
+
+    rive::Renderer* beginCanvasContent(rive::gpu::RenderCanvas* canvas,
+                                       uint32_t clearColor) override
+    {
+        m_activeCanvas = canvas;
+        rive::gpu::RenderContext::FrameDescriptor d{};
+        d.renderTargetWidth = canvas->width();
+        d.renderTargetHeight = canvas->height();
+        d.loadAction = rive::gpu::LoadAction::clear;
+        d.clearColor = clearColor;
+        m_rc->beginFrame(d);
+        m_canvasRenderer = new rive::RiveRenderer(m_rc);
+        return m_canvasRenderer;
+    }
+    void endCanvasContent() override
+    {
+        if (m_activeCanvas == nullptr)
+            return;
+        void* cb = m_rc->impl()->makeCommandBuffer();
+        rive::gpu::RenderContext::FlushResources fr{};
+        fr.renderTarget = m_activeCanvas->renderTarget();
+        fr.externalCommandBuffer = cb;
+        m_rc->flush(fr);
+        m_rc->impl()->commitCommandBuffer(cb);
+        delete m_canvasRenderer;
+        m_canvasRenderer = nullptr;
+        m_activeCanvas = nullptr;
+    }
+
+private:
+    rive::gpu::RenderContext* m_rc;
+    bool m_doClear;
+    std::unique_ptr<rive::Renderer> m_screen;
+    rive::RiveRenderer* m_canvasRenderer = nullptr;
+    rive::gpu::RenderCanvas* m_activeCanvas = nullptr;
+};
+
+// RIVE_GOLDENS_BENCH=<iters> loads the same scene immediate and deferred and
+// reports per frame record cost, replay cost, and stream size.
+void run_benchmark(const std::vector<uint8_t>& bytes,
+                   const char* artboardName,
+                   const char* stateMachineName,
+                   int iters);
+#endif
+
+class RIVLoader
+{
+public:
+    // Auto defers for --deferred, or RIVE_GOLDENS_DEFER_ORE with a single
+    // cell. The benchmark forces one or the other to load both side by side.
+    enum class DeferMode
+    {
+        Auto,
+        Immediate,
+        Deferred
+    };
+
+    RIVLoader(const std::vector<uint8_t>& rivBytes,
+              const char* artboardName,
+              const char* stateMachineName,
+              DeferMode mode = DeferMode::Auto)
+    {
+        rive::Factory* importFactory = TestingWindow::Get()->factory();
+#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS)
+        // Importing through the DeferredSession makes the artboard's own 2D
+        // resources deferred objects with ids so drawInternal can record.
+        bool wantDeferred =
+            mode == DeferMode::Deferred ||
+            (mode == DeferMode::Auto &&
+             (s_args.deferred() || (goldens_getenv("RIVE_GOLDENS_DEFER_ORE") &&
+                                    s_args.cols() * s_args.rows() == 1)));
+        if (wantDeferred)
+        {
+            if (auto* rc = TestingWindow::Get()->renderContext())
+            {
+                if (auto* ore = rc->getOreContext())
+                {
+                    m_session =
+                        std::make_unique<rive::cmd::DeferredSession>(ore);
+                    importFactory = m_session.get();
+                }
+            }
+        }
+#endif
+        m_file = rive::File::import(rivBytes, importFactory);
+        if (m_file == nullptr)
+        {
+            throw "Bad riv file";
+        }
+#ifdef WITH_RIVE_SCRIPTING
+        // Without the RenderContext handed over like a real host does,
+        // gpuCanvas bails and no GPU work runs.
+        if (auto* vm = m_file->scriptingVM())
+        {
+            vm->context()->setRenderContext(
+                TestingWindow::Get()->renderContext());
+#if defined(RIVE_CANVAS)
+            if (m_session)
+            {
+                vm->context()->setOreContext(&m_session->oreContext());
+                // Regular canvas 2D content records into the deferred stream.
+                vm->context()->setDeferredCanvasHost(m_session.get());
+            }
+#endif
+        }
+#endif
+        if (artboardName != nullptr && artboardName[0] != '\0')
+        {
+            m_artboard = m_file->artboardNamed(artboardName);
+        }
+        else
+        {
+            m_artboard = m_file->artboardDefault();
+        }
+        if (m_artboard == nullptr)
+        {
+            throw "Can't load artboard";
+        }
+
+        // Bind the default view model instance
+        m_viewModelInstance = m_file->createViewModelInstance(m_artboard.get());
+        m_artboard->bindViewModelInstance(m_viewModelInstance);
+
+        if (stateMachineName != nullptr && stateMachineName[0] != '\0')
+        {
+            m_scene = m_artboard->stateMachineNamed(stateMachineName);
+        }
+        else
+        {
+            m_scene = m_artboard->defaultStateMachine();
+        }
+
+        if (m_scene == nullptr)
+        {
+            // This is a riv without any state machines. Just draw the artboard.
+            m_scene = std::make_unique<rive::StaticScene>(m_artboard.get());
+        }
+
+        if (m_scene != nullptr && m_viewModelInstance != nullptr)
+        {
+            m_scene->bindViewModelInstance(m_viewModelInstance);
+        }
+    }
+
+    rive::Scene* stateMachine() const { return m_scene.get(); }
+    rive::Artboard* artboard() const { return m_artboard.get(); }
+
+    // Null when deferred mode is off.
+    rive::cmd::DeferredSession* deferredSession() const
+    {
+#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS)
+        return m_session.get();
+#else
+        return nullptr;
+#endif
+    }
+
+private:
+    // Destroyed last since deferred resources held by the file record their
+    // destruction into the session, so it must outlive them.
+#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS)
+    std::unique_ptr<rive::cmd::DeferredSession> m_session;
+#endif
+    rive::rcp<rive::File> m_file;
+    std::unique_ptr<rive::ArtboardInstance> m_artboard;
+    std::unique_ptr<rive::Scene> m_scene;
+    rive::rcp<rive::ViewModelInstance> m_viewModelInstance;
+};
+
+#endif // TESTING
diff --git a/tests/player/player.cpp b/tests/player/player.cpp
index 9c5b8e6..66b0364 100644
--- a/tests/player/player.cpp
+++ b/tests/player/player.cpp
@@ -340,11 +340,6 @@
             renderer->save();
             for (int x = -copiesLeft; x <= copiesRight; ++x)
             {
-                // drawInternal skips drawCanvases. In the future we can
-                // pre-pass them, but ORE commandBuffers are currently wired up
-                // in a way that causes severe perf regressions and flickering
-                // on Vulkan. Once deferred rendering is finished we can turn
-                // pre-passes back on.
                 m_artboard->drawInternal(renderer.get());
                 renderer->translate(spacingPx, 0);
             }
diff --git a/tests/premake5.lua b/tests/premake5.lua
index 23905ea..465a0a4 100644
--- a/tests/premake5.lua
+++ b/tests/premake5.lua
@@ -6,7 +6,12 @@
 })
 
 if not _OPTIONS['for_unreal'] then
-    rive_tools_project('bench', _OPTIONS['os'] == 'ios' and 'StaticLib' or _OPTIONS['all_tools_as_static'] and 'StaticLib' or 'ConsoleApp' )
+    rive_tools_project(
+        'bench',
+        _OPTIONS['os'] == 'ios' and 'StaticLib'
+            or _OPTIONS['all_tools_as_static'] and 'StaticLib'
+            or 'ConsoleApp'
+    )
     do
         files({ 'bench/*.cpp' })
     end
@@ -14,7 +19,15 @@
 
 rive_tools_project('gms', 'RiveTool')
 do
-    files({ 'gm/*.cpp'})
+    files({ 'gm/*.cpp' })
+    -- Deferred-rendering 2D record/replay (SerializingFactory + the replay that
+    -- drives a real Factory/Renderer) so GMs can verify 2D replay against PLS.
+    files({
+        '../utils/serializing_factory.cpp',
+        '../utils/serialized_replay.cpp',
+    })
+    -- serializing_factory.cpp decodes images (decoders header).
+    includedirs({ '../decoders/include' })
     -- Ore GM tests need Obj-C++ on Apple (ore headers include <Metal/Metal.h>).
     -- .mm wrappers #include the .cpp files so every Apple generator compiles
     -- them as Obj-C++ without needing compileas or buildoptions hacks.
@@ -50,7 +63,7 @@
     filter({})
     filter({ 'options:not no_tools_shader_hotloading' })
     do
-        files({RIVE_PLS_DIR .. '/shader_hotload/**.cpp' })
+        files({ RIVE_PLS_DIR .. '/shader_hotload/**.cpp' })
     end
     filter({ 'options:for_unreal' })
     do
@@ -65,10 +78,15 @@
 rive_tools_project('goldens', 'RiveTool')
 do
     exceptionhandling('On')
-    files({ 'goldens/goldens.cpp'})
+    files({ 'goldens/goldens.cpp', 'goldens/goldens_bench.cpp' })
+    -- The deferred recording factory (deferred_render_factory.hpp) decodes image
+    -- dimensions at record time so the artboard's layout sees real sizes; needs
+    -- the decoder header + RIVE_DECODERS (the lib is already linked).
+    includedirs({ '../decoders/include' })
+    defines({ 'RIVE_DECODERS' })
     filter({ 'options:not no_tools_shader_hotloading' })
     do
-        files({RIVE_PLS_DIR .. '/shader_hotload/**.cpp' })
+        files({ RIVE_PLS_DIR .. '/shader_hotload/**.cpp' })
     end
     filter({ 'options:for_unreal' })
     do
@@ -82,7 +100,7 @@
 
 rive_tools_project('player', 'RiveTool')
 do
-    files({ 'player/player.cpp'})
+    files({ 'player/player.cpp' })
     filter('system:emscripten')
     do
         files({ 'player/player.html' })
@@ -90,6 +108,6 @@
 
     filter({ 'options:not no_tools_shader_hotloading' })
     do
-        files({RIVE_PLS_DIR .. '/shader_hotload/**.cpp' })
+        files({ RIVE_PLS_DIR .. '/shader_hotload/**.cpp' })
     end
 end
diff --git a/tests/unit_tests/assets/parity/Halloween_v3.riv b/tests/unit_tests/assets/parity/Halloween_v3.riv
new file mode 100644
index 0000000..5d1677b
--- /dev/null
+++ b/tests/unit_tests/assets/parity/Halloween_v3.riv
Binary files differ
diff --git a/tests/unit_tests/assets/parity/Knight_square_2.riv b/tests/unit_tests/assets/parity/Knight_square_2.riv
new file mode 100644
index 0000000..d5bf938
--- /dev/null
+++ b/tests/unit_tests/assets/parity/Knight_square_2.riv
Binary files differ
diff --git a/tests/unit_tests/assets/parity/Tom_Morello.riv b/tests/unit_tests/assets/parity/Tom_Morello.riv
new file mode 100644
index 0000000..f1c0b75
--- /dev/null
+++ b/tests/unit_tests/assets/parity/Tom_Morello.riv
Binary files differ
diff --git a/tests/unit_tests/assets/parity/UI_Swipe_left_to_delete.riv b/tests/unit_tests/assets/parity/UI_Swipe_left_to_delete.riv
new file mode 100644
index 0000000..5e1dbf2
--- /dev/null
+++ b/tests/unit_tests/assets/parity/UI_Swipe_left_to_delete.riv
Binary files differ
diff --git a/tests/unit_tests/assets/parity/falling.riv b/tests/unit_tests/assets/parity/falling.riv
new file mode 100644
index 0000000..dac76ad
--- /dev/null
+++ b/tests/unit_tests/assets/parity/falling.riv
Binary files differ
diff --git a/tests/unit_tests/assets/parity/popsicle_loader.riv b/tests/unit_tests/assets/parity/popsicle_loader.riv
new file mode 100644
index 0000000..2d7ba67
--- /dev/null
+++ b/tests/unit_tests/assets/parity/popsicle_loader.riv
Binary files differ
diff --git a/tests/unit_tests/renderer/canvas_schedule_test.cpp b/tests/unit_tests/renderer/canvas_schedule_test.cpp
new file mode 100644
index 0000000..eb688fa
--- /dev/null
+++ b/tests/unit_tests/renderer/canvas_schedule_test.cpp
@@ -0,0 +1,276 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Sampler canvases replay after the canvases they sample regardless of
+// record order. Pure byte math, no GPU.
+
+#include "deferred_test_sink.hpp"
+#include "rive/renderer/cmd/canvas_schedule.hpp"
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#include "rive/renderer/render_canvas.hpp"
+
+#include <catch.hpp>
+
+using namespace rive;
+using namespace rive::cmd;
+using Target = DeferredSegment::Target;
+
+namespace
+{
+// A hand-built 2D stream plus its canvas segments.
+struct StreamBuilder
+{
+    std::vector<uint8_t> bytes;
+    std::vector<DeferredSegment> segments;
+
+    template <typename POD> void append(RenderCmd c, const POD& pod)
+    {
+        bytes.push_back(static_cast<uint8_t>(c));
+        const uint8_t* p = reinterpret_cast<const uint8_t*>(&pod);
+        bytes.insert(bytes.end(), p, p + sizeof(POD));
+    }
+
+    // Records a canvas bracket holding the given flagged image samples.
+    void canvasRange(uint64_t canvasId,
+                     std::initializer_list<uint64_t> sampledCanvasIds)
+    {
+        uint32_t begin = static_cast<uint32_t>(bytes.size());
+        // Noise the walker must skip.
+        DrawPathPOD path = {};
+        append(RenderCmd::drawPath, path);
+        for (uint64_t sampled : sampledCanvasIds)
+        {
+            DrawImagePOD draw = {};
+            draw.image = kCanvasHandleFlag | static_cast<RenderHandle>(sampled);
+            append(RenderCmd::drawImage, draw);
+        }
+        segments.push_back({Target::canvas,
+                            canvasId,
+                            begin,
+                            static_cast<uint32_t>(bytes.size())});
+    }
+
+    // A foreign image draw that is not a written canvas (host image).
+    void canvasRangeSamplingForeign(uint64_t canvasId, uint32_t foreignIndex)
+    {
+        uint32_t begin = static_cast<uint32_t>(bytes.size());
+        DrawImagePOD draw = {};
+        draw.image = kCanvasHandleFlag | foreignIndex;
+        append(RenderCmd::drawImage, draw);
+        segments.push_back({Target::canvas,
+                            canvasId,
+                            begin,
+                            static_cast<uint32_t>(bytes.size())});
+    }
+
+    CanvasSchedule schedule() const
+    {
+        return scheduleCanvases(Span<const uint8_t>(bytes.data(), bytes.size()),
+                                segments);
+    }
+};
+} // namespace
+
+TEST_CASE("in-order sampler keeps record order", "[cmd][canvas-dag]")
+{
+    StreamBuilder b;
+    b.canvasRange(1, {});  // A writes
+    b.canvasRange(2, {1}); // B samples A, recorded after
+    auto s = b.schedule();
+    REQUIRE(s.order == std::vector<uint64_t>{1, 2});
+    CHECK_FALSE(s.hadCycle);
+    CHECK_FALSE(s.multiWriteFallback);
+}
+
+TEST_CASE("reader recorded before its writer reorders", "[cmd][canvas-dag]")
+{
+    StreamBuilder b;
+    b.canvasRange(2, {1}); // B samples A but records first
+    b.canvasRange(1, {});  // A writes
+    auto s = b.schedule();
+    REQUIRE(s.order == std::vector<uint64_t>{1, 2});
+    CHECK_FALSE(s.hadCycle);
+}
+
+TEST_CASE("reversed three-canvas chain schedules writer first",
+          "[cmd][canvas-dag]")
+{
+    StreamBuilder b;
+    b.canvasRange(3, {2}); // C samples B
+    b.canvasRange(2, {1}); // B samples A
+    b.canvasRange(1, {});  // A writes last in record order
+    auto s = b.schedule();
+    REQUIRE(s.order == std::vector<uint64_t>{1, 2, 3});
+}
+
+TEST_CASE("cycle demotes to record order and flags", "[cmd][canvas-dag]")
+{
+    StreamBuilder b;
+    b.canvasRange(1, {2}); // A samples B
+    b.canvasRange(2, {1}); // B samples A
+    auto s = b.schedule();
+    REQUIRE(s.order == std::vector<uint64_t>{1, 2});
+    CHECK(s.hadCycle);
+}
+
+TEST_CASE("self sample is a demoted edge, not a reorder", "[cmd][canvas-dag]")
+{
+    StreamBuilder b;
+    b.canvasRange(1, {1});
+    auto s = b.schedule();
+    REQUIRE(s.order == std::vector<uint64_t>{1});
+    CHECK(s.hadCycle);
+}
+
+TEST_CASE("sampling an unwritten id adds no edge", "[cmd][canvas-dag]")
+{
+    StreamBuilder b;
+    b.canvasRangeSamplingForeign(1, 7); // host image or unwritten canvas
+    b.canvasRange(2, {});
+    auto s = b.schedule();
+    REQUIRE(s.order == std::vector<uint64_t>{1, 2});
+    CHECK_FALSE(s.hadCycle);
+}
+
+TEST_CASE("read between two writes of one canvas falls back",
+          "[cmd][canvas-dag]")
+{
+    StreamBuilder b;
+    b.canvasRange(1, {});  // A@v1
+    b.canvasRange(2, {1}); // B samples A mid-frame
+    b.canvasRange(1, {});  // A writes again
+    auto s = b.schedule();
+    REQUIRE(s.order == std::vector<uint64_t>{1, 2});
+    CHECK(s.multiWriteFallback);
+}
+
+TEST_CASE("drawImageMesh creates edges like drawImage", "[cmd][canvas-dag]")
+{
+    StreamBuilder b;
+    uint32_t begin = static_cast<uint32_t>(b.bytes.size());
+    DrawImageMeshPOD mesh = {};
+    mesh.image = kCanvasHandleFlag | 1u;
+    b.append(RenderCmd::drawImageMesh, mesh);
+    b.segments.push_back(
+        {Target::canvas, 2, begin, static_cast<uint32_t>(b.bytes.size())});
+    b.canvasRange(1, {});
+    auto s = b.schedule();
+    REQUIRE(s.order == std::vector<uint64_t>{1, 2});
+}
+
+TEST_CASE("independent canvases keep record order among themselves",
+          "[cmd][canvas-dag]")
+{
+    StreamBuilder b;
+    b.canvasRange(3, {});
+    b.canvasRange(1, {5}); // samples a later writer
+    b.canvasRange(4, {});
+    b.canvasRange(5, {});
+    auto s = b.schedule();
+    // 5 must precede 1; 3 and 4 stay put relative to everyone they can.
+    REQUIRE(s.order == std::vector<uint64_t>{3, 4, 5, 1});
+}
+
+namespace
+{
+struct FakeTarget : gpu::RenderTarget
+{
+    FakeTarget() : RenderTarget(8, 8) {}
+};
+
+struct FakeImage : RiveRenderImage
+{
+    FakeImage() : RiveRenderImage(8, 8) {}
+};
+
+// Logs canvas frame open order; canvas draws drop against the null renderer.
+class OrderSink : public deferred_test::TestSink
+{
+public:
+    std::vector<gpu::RenderCanvas*> opened;
+    Renderer* beginCanvasContent(gpu::RenderCanvas* canvas, uint32_t) override
+    {
+        opened.push_back(canvas);
+        return nullptr;
+    }
+};
+
+rcp<gpu::RenderCanvas> fakeCanvas()
+{
+    return make_rcp<gpu::RenderCanvas>(make_rcp<FakeImage>(),
+                                       make_rcp<FakeTarget>());
+}
+} // namespace
+
+TEST_CASE("replay opens the sampled canvas before its reader despite record "
+          "order",
+          "[cmd][canvas-dag]")
+{
+    DeferredSession session(nullptr);
+    auto canvasA = fakeCanvas();
+    auto canvasB = fakeCanvas();
+
+    // B samples A but records first, exactly as a script may issue it.
+    Renderer* b = session.beginCanvasContent(canvasB.get(), 0);
+    b->drawImage(canvasA->renderImage(), {}, BlendMode::srcOver, 1.0f);
+    session.endCanvasContent(canvasB.get());
+    Renderer* a = session.beginCanvasContent(canvasA.get(), 0);
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    a->drawPath(path.get(), paint.get());
+    session.endCanvasContent(canvasA.get());
+    session.closeOpenRange();
+
+    auto frame = snapshotFrame(session);
+    OrderSink sink;
+    DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+
+    REQUIRE(sink.opened.size() == 2);
+    CHECK(sink.opened[0] == canvasA.get());
+    CHECK(sink.opened[1] == canvasB.get());
+}
+
+TEST_CASE("a canvas only frame still opens a screen frame", "[cmd][canvas-dag]")
+{
+    DeferredSession session(nullptr);
+    auto canvas = fakeCanvas();
+
+    Renderer* c = session.beginCanvasContent(canvas.get(), 0);
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    c->drawPath(path.get(), paint.get());
+    session.endCanvasContent(canvas.get());
+    session.closeOpenRange();
+
+    auto frame = snapshotFrame(session);
+    OrderSink sink;
+    DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+
+    REQUIRE(sink.opened.size() == 1);
+    // The screen frame is where the host's clear and present live, so a frame
+    // that only fills canvases still owes its target one.
+    CHECK(sink.openedTargets() == 1);
+}
+
+TEST_CASE("a frame that only creates resources opens no screen frame",
+          "[cmd][canvas-dag]")
+{
+    DeferredSession session(nullptr);
+    // Creates land outside every renderer. Attributing them would open a
+    // target that drew nothing, which is why they stay unattributed.
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    session.closeOpenRange();
+
+    auto frame = snapshotFrame(session);
+    REQUIRE_FALSE(frame.commands.empty());
+
+    OrderSink sink;
+    DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+
+    CHECK(sink.openedTargets() == 0);
+}
diff --git a/tests/unit_tests/renderer/deferred_flush_parity_test.cpp b/tests/unit_tests/renderer/deferred_flush_parity_test.cpp
new file mode 100644
index 0000000..62c0c9b
--- /dev/null
+++ b/tests/unit_tests/renderer/deferred_flush_parity_test.cpp
@@ -0,0 +1,361 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Gate for the deferred recording changes: the same riv drawn immediately and
+// drawn through a record + replay round trip must ask the render context for
+// the same GPU work, flush for flush. Ported from the draw-time serialization
+// tree so both sides are held to one bar.
+
+#include "rive/renderer/cmd/deferred_render_factory.hpp"
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "rive/renderer/rive_renderer.hpp"
+#include "common/render_context_null.hpp"
+#include "rive_file_reader.hpp"
+#include "rive/scene.hpp"
+
+#include <catch.hpp>
+#include <algorithm>
+#include <cmath>
+#include <filesystem>
+#include <string>
+#include <vector>
+
+using namespace rive;
+using namespace rive::cmd;
+
+namespace
+{
+constexpr int kFrames = 30;
+constexpr int kFirstSteadyFrame = 2;
+constexpr float kFrameSeconds = 1.0f / 60;
+
+void advanceFrame(Scene* scene, ArtboardInstance* artboard, int frame)
+{
+    float seconds = frame == 0 ? 0 : kFrameSeconds;
+    if (scene != nullptr)
+    {
+        scene->advanceAndApply(seconds);
+    }
+    else
+    {
+        artboard->advance(seconds);
+    }
+}
+
+void drawFrame(Scene* scene, ArtboardInstance* artboard, Renderer* renderer)
+{
+    renderer->save();
+    if (scene != nullptr)
+    {
+        scene->draw(renderer);
+    }
+    else
+    {
+        artboard->draw(renderer);
+    }
+    renderer->restore();
+}
+
+// What one frame asked of the render context, summed over its flushes.
+struct FlushStats
+{
+    uint64_t flushes = 0;
+    uint64_t pathCount = 0;
+    uint64_t contourCount = 0;
+    uint64_t tessVertexSpans = 0;
+    uint64_t gradSpans = 0;
+    uint64_t gradDataHeight = 0;
+    uint64_t tessDataHeight = 0;
+    uint64_t atlasFillBatches = 0;
+    uint64_t atlasStrokeBatches = 0;
+    uint64_t atlasContentArea = 0;
+};
+
+FlushStats operator-(const FlushStats& a, const FlushStats& b)
+{
+    return {a.flushes - b.flushes,
+            a.pathCount - b.pathCount,
+            a.contourCount - b.contourCount,
+            a.tessVertexSpans - b.tessVertexSpans,
+            a.gradSpans - b.gradSpans,
+            a.gradDataHeight - b.gradDataHeight,
+            a.tessDataHeight - b.tessDataHeight,
+            a.atlasFillBatches - b.atlasFillBatches,
+            a.atlasStrokeBatches - b.atlasStrokeBatches,
+            a.atlasContentArea - b.atlasContentArea};
+}
+
+class FlushObservingNULL : public RenderContextNULL
+{
+public:
+    FlushStats stats;
+    uint32_t featuresEver = 0; // union of combinedShaderFeatures
+
+    void flush(const gpu::FlushDescriptor& d) override
+    {
+        featuresEver |= static_cast<uint32_t>(d.combinedShaderFeatures);
+        stats.flushes++;
+        stats.pathCount += d.pathCount;
+        stats.contourCount += d.contourCount;
+        stats.tessVertexSpans += d.tessVertexSpanCount;
+        stats.gradSpans += d.gradSpanCount;
+        stats.gradDataHeight += d.gradDataHeight;
+        stats.tessDataHeight += d.tessDataHeight;
+        stats.atlasFillBatches += d.featherAtlasFillBatchCount;
+        stats.atlasStrokeBatches += d.featherAtlasStrokeBatchCount;
+        stats.atlasContentArea += uint64_t(d.featherAtlasContentWidth) *
+                                  uint64_t(d.featherAtlasContentHeight);
+    }
+};
+
+class ObservingContext : public gpu::RenderContext
+{
+public:
+    ObservingContext() : RenderContext(std::make_unique<FlushObservingNULL>())
+    {}
+    FlushObservingNULL* observer()
+    {
+        return static_impl_cast<FlushObservingNULL>();
+    }
+};
+
+std::vector<FlushStats> runImmediate(const char* rivPath, uint32_t* features)
+{
+    ObservingContext ctx;
+    auto file = ReadRiveFile(rivPath, &ctx);
+    auto artboard = file->artboardDefault();
+    auto scene = artboard->defaultScene();
+    uint32_t w = static_cast<uint32_t>(std::ceil(artboard->width()));
+    uint32_t h = static_cast<uint32_t>(std::ceil(artboard->height()));
+    auto rt = ctx.observer()->makeRenderTarget(w, h);
+
+    std::vector<FlushStats> frames;
+    for (int frame = 0; frame < kFrames; frame++)
+    {
+        advanceFrame(scene.get(), artboard.get(), frame);
+        FlushStats before = ctx.observer()->stats;
+        ctx.beginFrame({.renderTargetWidth = w, .renderTargetHeight = h});
+        RiveRenderer renderer(&ctx);
+        drawFrame(scene.get(), artboard.get(), &renderer);
+        ctx.flush({.renderTarget = rt.get()});
+        frames.push_back(ctx.observer()->stats - before);
+    }
+    *features = ctx.observer()->featuresEver;
+    return frames;
+}
+
+// Opens the real screen frame on the observed context, like the host sinks.
+class NullContextSink : public DeferredFrameSink
+{
+public:
+    NullContextSink(ObservingContext* ctx, uint32_t w, uint32_t h) :
+        m_ctx(ctx), m_width(w), m_height(h)
+    {}
+
+    Factory* factory() override { return m_ctx; }
+    ore::Context* oreContext() override { return nullptr; }
+    // Parity is defined against the single render target the immediate side
+    // draws, so a second target would have nothing to compare with.
+    Renderer* beginScreenFrame(uint64_t target) override
+    {
+        REQUIRE(target == 0);
+        m_ctx->beginFrame(
+            {.renderTargetWidth = m_width, .renderTargetHeight = m_height});
+        m_renderer = std::make_unique<RiveRenderer>(m_ctx);
+        return m_renderer.get();
+    }
+    bool frameOpen() const { return m_renderer != nullptr; }
+    void closeFrame() { m_renderer = nullptr; }
+
+private:
+    ObservingContext* m_ctx;
+    uint32_t m_width, m_height;
+    std::unique_ptr<RiveRenderer> m_renderer;
+};
+
+std::vector<FlushStats> runDeferred(const char* rivPath)
+{
+    DeferredSession session(nullptr);
+    auto file = ReadRiveFile(rivPath, &session);
+    auto artboard = file->artboardDefault();
+    auto scene = artboard->defaultScene();
+    uint32_t w = static_cast<uint32_t>(std::ceil(artboard->width()));
+    uint32_t h = static_cast<uint32_t>(std::ceil(artboard->height()));
+
+    ObservingContext ctx;
+    auto rt = ctx.observer()->makeRenderTarget(w, h);
+    NullContextSink sink(&ctx, w, h);
+    DeferredReplayer replayer;
+
+    std::vector<FlushStats> frames;
+    for (int frame = 0; frame < kFrames; frame++)
+    {
+        advanceFrame(scene.get(), artboard.get(), frame);
+        drawFrame(scene.get(), artboard.get(), session.screenRenderer());
+        DeferredFrame snapshot = snapshotFrame(session);
+        session.resetFrame();
+
+        FlushStats before = ctx.observer()->stats;
+        replayer.replayFrame(snapshot, sink);
+        CHECK(replayer.droppedDraws() == 0);
+        if (sink.frameOpen())
+        {
+            ctx.flush({.renderTarget = rt.get()});
+            sink.closeFrame();
+        }
+        frames.push_back(ctx.observer()->stats - before);
+    }
+    return frames;
+}
+
+void printFlushParity(const char* name,
+                      const std::vector<FlushStats>& imm,
+                      const std::vector<FlushStats>& def)
+{
+    auto steadyAvg = [](const std::vector<FlushStats>& v, auto pick) {
+        double sum = 0;
+        for (size_t i = kFirstSteadyFrame; i < v.size(); i++)
+        {
+            sum += static_cast<double>(pick(v[i]));
+        }
+        return sum / static_cast<double>(v.size() - kFirstSteadyFrame);
+    };
+    printf("\n== %s flush parity (steady state, per frame) ==\n", name);
+    printf("  %-18s %12s %12s\n", "", "immediate", "deferred");
+    auto row = [&](const char* label, auto pick) {
+        printf("  %-18s %12.1f %12.1f\n",
+               label,
+               steadyAvg(imm, pick),
+               steadyAvg(def, pick));
+    };
+    row("flushes", [](const FlushStats& s) { return s.flushes; });
+    row("paths", [](const FlushStats& s) { return s.pathCount; });
+    row("contours", [](const FlushStats& s) { return s.contourCount; });
+    row("tessSpans", [](const FlushStats& s) { return s.tessVertexSpans; });
+    row("tessDataHeight", [](const FlushStats& s) { return s.tessDataHeight; });
+    row("gradSpans", [](const FlushStats& s) { return s.gradSpans; });
+    row("gradDataHeight", [](const FlushStats& s) { return s.gradDataHeight; });
+    row("atlasFillBatches",
+        [](const FlushStats& s) { return s.atlasFillBatches; });
+    row("atlasStrokeBatches",
+        [](const FlushStats& s) { return s.atlasStrokeBatches; });
+    row("atlasContentArea",
+        [](const FlushStats& s) { return s.atlasContentArea; });
+}
+
+void printShaderFeatures(uint32_t features)
+{
+    static const char* kNames[] = {"CLIPPING",
+                                   "CLIP_RECT",
+                                   "ADVANCED_BLEND",
+                                   "FEATHER",
+                                   "EVEN_ODD",
+                                   "NESTED_CLIPPING",
+                                   "HSL_BLEND_MODES",
+                                   "DITHER"};
+    printf("  shader features:");
+    for (size_t i = 0; i < 8; i++)
+    {
+        if (features & (1u << i))
+        {
+            printf(" %s", kNames[i]);
+        }
+    }
+    printf("\n");
+}
+
+// A missing riv fails the gate rather than passing vacuously.
+void requireRiv(const std::string& path)
+{
+    FILE* fp = fopen(path.c_str(), "rb");
+    if (fp == nullptr)
+    {
+        FAIL("flush parity riv missing: " << path);
+    }
+    fclose(fp);
+}
+
+void checkParity(const char* name,
+                 const std::vector<FlushStats>& imm,
+                 const std::vector<FlushStats>& def)
+{
+    // Equal flush structure means recording changed nothing the renderer can
+    // see.
+    for (size_t i = kFirstSteadyFrame; i < imm.size(); i++)
+    {
+        INFO(name << " frame " << i);
+        CHECK(imm[i].flushes == def[i].flushes);
+        CHECK(imm[i].tessVertexSpans == def[i].tessVertexSpans);
+        CHECK(imm[i].atlasContentArea == def[i].atlasContentArea);
+        CHECK(imm[i].gradDataHeight == def[i].gradDataHeight);
+    }
+}
+
+void flushParity(const char* name)
+{
+    // Plain git assets so device deploys carry real bytes, not lfs pointers.
+    std::string path = std::string("assets/parity/") + name;
+    requireRiv(path);
+    uint32_t features = 0;
+    auto imm = runImmediate(path.c_str(), &features);
+    auto def = runDeferred(path.c_str());
+    printFlushParity(name, imm, def);
+    printShaderFeatures(features);
+    checkParity(name, imm, def);
+}
+
+// Whole corpus sweep, so properties the six named rivs never exercise
+// (feathers above all) are still held to parity. Hidden because it is slow
+// and needs the full lfs corpus; run with test.sh -m "[.corpus_parity]".
+void flushParityQuiet(const std::string& name)
+{
+    std::string path = std::string("../../../../zzzgold/rivs/") + name;
+    FILE* fp = fopen(path.c_str(), "rb");
+    if (fp == nullptr)
+    {
+        return;
+    }
+    fclose(fp);
+    uint32_t features = 0;
+    auto imm = runImmediate(path.c_str(), &features);
+    auto def = runDeferred(path.c_str());
+    checkParity(name.c_str(), imm, def);
+}
+} // namespace
+
+TEST_CASE("deferred flush parity, regressing rivs", "[deferred_flush_parity]")
+{
+    flushParity("Halloween_v3.riv");
+    flushParity("UI_Swipe_left_to_delete.riv");
+    flushParity("Tom_Morello.riv");
+}
+
+TEST_CASE("deferred flush parity, parity rivs", "[deferred_flush_parity]")
+{
+    flushParity("Knight_square_2.riv");
+    flushParity("falling.riv");
+    flushParity("popsicle_loader.riv");
+}
+
+TEST_CASE("deferred flush parity, whole corpus", "[.][corpus_parity]")
+{
+    std::vector<std::string> names;
+    std::error_code ec;
+    for (const auto& e :
+         std::filesystem::directory_iterator("../../../../zzzgold/rivs/", ec))
+    {
+        std::string n = e.path().filename().string();
+        if (n.size() > 4 && n.compare(n.size() - 4, 4, ".riv") == 0)
+        {
+            names.push_back(n);
+        }
+    }
+    std::sort(names.begin(), names.end());
+    printf("corpus flush parity over %zu rivs\n", names.size());
+    for (const std::string& n : names)
+    {
+        flushParityQuiet(n);
+    }
+}
diff --git a/tests/unit_tests/renderer/deferred_measure_test.cpp b/tests/unit_tests/renderer/deferred_measure_test.cpp
new file mode 100644
index 0000000..7d6d070
--- /dev/null
+++ b/tests/unit_tests/renderer/deferred_measure_test.cpp
@@ -0,0 +1,644 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Measurement harness for the draw-time serialization work. Deliberately
+// source identical between the pre-refactor and post-refactor trees so the
+// two sides can be compared opcode for opcode and microsecond for
+// microsecond. Emits machine readable rows; nothing here asserts on a
+// threshold.
+//
+// Hidden tag; run explicitly with test.sh -m "[deferred_measure]".
+//
+// Environment:
+//   RIVE_MEASURE_FRAMES   total frames per riv           (default 3000)
+//   RIVE_MEASURE_WARMUP   frames treated as transient    (default 300)
+//   RIVE_MEASURE_RIVS     comma separated riv names, or "all" for the
+//                         whole zzzgold corpus           (default: a
+//                         6 riv short list)
+//   RIVE_MEASURE_SESSIONS concurrent sessions for the resident table
+//                         sizing case                    (default 8)
+
+#include "rive/renderer/cmd/deferred_render_factory.hpp"
+#include "rive/renderer/cmd/deferred_render_resource.hpp"
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "rive/renderer/cmd/render_commands.hpp"
+#include "rive/renderer/cmd/render_replay.hpp"
+#include "rive_file_reader.hpp"
+#include "rive/scene.hpp"
+#include "utils/factory_utils.hpp"
+#include "utils/no_op_renderer.hpp"
+
+#include <catch.hpp>
+#include <algorithm>
+#include <chrono>
+#include <cstdlib>
+#include <cstring>
+#include <filesystem>
+#include <string>
+#include <type_traits>
+#include <vector>
+
+using namespace rive;
+using namespace rive::cmd;
+
+namespace
+{
+// ---- configuration ----
+
+int envInt(const char* name, int fallback)
+{
+    const char* v = getenv(name);
+    return v != nullptr && *v != '\0' ? atoi(v) : fallback;
+}
+
+const char* kRivDir = "../../../../zzzgold/rivs/";
+
+std::vector<std::string> corpus()
+{
+    const char* v = getenv("RIVE_MEASURE_RIVS");
+    std::string spec = v != nullptr && *v != '\0' ? v : "";
+    std::vector<std::string> names;
+    if (spec == "all")
+    {
+        std::error_code ec;
+        for (const auto& e : std::filesystem::directory_iterator(kRivDir, ec))
+        {
+            std::string n = e.path().filename().string();
+            if (n.size() > 4 && n.compare(n.size() - 4, 4, ".riv") == 0)
+            {
+                names.push_back(n);
+            }
+        }
+        std::sort(names.begin(), names.end());
+        return names;
+    }
+    if (spec.empty())
+    {
+        return {"Halloween_v3.riv",
+                "UI_Swipe_left_to_delete.riv",
+                "Tom_Morello.riv",
+                "Knight_square_2.riv",
+                "falling.riv",
+                "popsicle_loader.riv"};
+    }
+    size_t start = 0;
+    while (start <= spec.size())
+    {
+        size_t comma = spec.find(',', start);
+        if (comma == std::string::npos)
+        {
+            comma = spec.size();
+        }
+        if (comma > start)
+        {
+            names.push_back(spec.substr(start, comma - start));
+        }
+        start = comma + 1;
+    }
+    return names;
+}
+
+// ---- sink ----
+
+// No-op resources: replay mutates them freely and the factory counts
+// creations, so consumer side object churn per frame is observable.
+class MPath : public RenderPath
+{
+public:
+    void rewind() override {}
+    void fillRule(FillRule) override {}
+    void addPath(CommandPath*, const Mat2D&) override {}
+    void addRenderPath(const RenderPath*, const Mat2D&) override {}
+    void addRawPath(const RawPath&) override {}
+    void moveTo(float, float) override {}
+    void lineTo(float, float) override {}
+    void cubicTo(float, float, float, float, float, float) override {}
+    void close() override {}
+};
+
+class MPaint : public RenderPaint
+{
+public:
+    void color(unsigned int) override {}
+    void style(RenderPaintStyle) override {}
+    void thickness(float) override {}
+    void join(StrokeJoin) override {}
+    void cap(StrokeCap) override {}
+    void blendMode(BlendMode) override {}
+    void shader(rcp<RenderShader>) override {}
+    void invalidateStroke() override {}
+    void feather(float) override {}
+};
+
+class MShader : public RenderShader
+{};
+class MImage : public RenderImage
+{};
+
+class MFactory : public Factory
+{
+public:
+    int paths = 0, paints = 0, shaders = 0, buffers = 0, images = 0;
+
+    rcp<RenderPath> makeRenderPath(RawPath&, FillRule) override
+    {
+        paths++;
+        return make_rcp<MPath>();
+    }
+    rcp<RenderPath> makeEmptyRenderPath() override
+    {
+        paths++;
+        return make_rcp<MPath>();
+    }
+    rcp<RenderPaint> makeRenderPaint() override
+    {
+        paints++;
+        return make_rcp<MPaint>();
+    }
+    rcp<RenderShader> makeLinearGradient(float,
+                                         float,
+                                         float,
+                                         float,
+                                         const ColorInt[],
+                                         const float[],
+                                         size_t) override
+    {
+        shaders++;
+        return make_rcp<MShader>();
+    }
+    rcp<RenderShader> makeRadialGradient(float,
+                                         float,
+                                         float,
+                                         const ColorInt[],
+                                         const float[],
+                                         size_t) override
+    {
+        shaders++;
+        return make_rcp<MShader>();
+    }
+    rcp<RenderBuffer> makeRenderBuffer(RenderBufferType t,
+                                       RenderBufferFlags f,
+                                       size_t s) override
+    {
+        buffers++;
+        return make_rcp<DataRenderBuffer>(t, f, s);
+    }
+    rcp<RenderImage> decodeImage(Span<const uint8_t>) override
+    {
+        images++;
+        return make_rcp<MImage>();
+    }
+};
+
+class MSink : public DeferredFrameSink
+{
+public:
+    MFactory f;
+    Factory* factory() override { return &f; }
+    ore::Context* oreContext() override { return nullptr; }
+    // The harness measures one session against one screen, and a no-op
+    // renderer has nothing to dispatch per target anyway.
+    Renderer* beginScreenFrame(uint64_t target) override
+    {
+        REQUIRE(target == 0);
+        return &m_renderer;
+    }
+
+private:
+    NoOpRenderer m_renderer;
+};
+
+// ---- retained geometry, present only on the post-refactor tree ----
+
+template <typename T, typename = void> struct HasRetained : std::false_type
+{};
+template <typename T>
+struct HasRetained<T, decltype(void(T::retainedGeometryBytes()))>
+    : std::true_type
+{};
+
+// Templated so the branch the tree does not have is never looked up: an
+// if constexpr in a plain function still requires both arms to name real
+// members.
+template <typename Path = DeferredRenderPath> int64_t retainedGeometry()
+{
+    if constexpr (HasRetained<Path>::value)
+    {
+        return Path::retainedGeometryBytes();
+    }
+    else
+    {
+        // The pre-refactor path serializes each mutation straight into the
+        // stream and holds no authoritative geometry, so there is nothing to
+        // report and no counter to read.
+        return -2;
+    }
+}
+
+// ---- stream census ----
+
+constexpr size_t kNumCmds = static_cast<size_t>(RenderCmd::lastRenderCmd) + 1;
+
+const char* cmdName(size_t i)
+{
+    switch (static_cast<RenderCmd>(i))
+    {
+#define RIVE_MEASURE_CMD_NAME(cmd, POD)                                        \
+    case RenderCmd::cmd:                                                       \
+        return #cmd;
+        RIVE_RENDER_CMD_TABLE(RIVE_MEASURE_CMD_NAME)
+#undef RIVE_MEASURE_CMD_NAME
+    }
+    return "?";
+}
+
+struct Census
+{
+    uint64_t count[kNumCmds] = {};
+    uint64_t geomBytes = 0;
+    uint64_t commandBytes = 0, blobBytes = 0;
+    uint64_t frames = 0;
+    bool overrun = false;
+
+    void add(const RenderCommandBuffer& buf)
+    {
+        frames++;
+        commandBytes += buf.commandBytes().size();
+        blobBytes += buf.blobBytes().size();
+        CommandReader<uint8_t> r(buf.commandBytes(), buf.blobBytes());
+        uint8_t type;
+        while (r.next(type))
+        {
+            if (type >= kNumCmds)
+            {
+                overrun = true;
+                break;
+            }
+            RenderCmd cmd = static_cast<RenderCmd>(type);
+            count[type]++;
+            switch (cmd)
+            {
+                case RenderCmd::makePath:
+                {
+                    auto c = r.read<MakePathPOD>();
+                    geomBytes += c.verbCount * sizeof(PathVerb) +
+                                 c.pointCount * sizeof(Vec2D);
+                    break;
+                }
+                case RenderCmd::pathAddRawPath:
+                {
+                    auto c = r.read<PathRawPOD>();
+                    geomBytes += c.verbCount * sizeof(PathVerb) +
+                                 c.pointCount * sizeof(Vec2D);
+                    break;
+                }
+                default:
+                    r.skip(payloadSizeOf(cmd));
+                    break;
+            }
+        }
+        overrun |= r.overrun();
+    }
+};
+
+// ---- one riv ----
+
+struct Phase
+{
+    double advanceUs = 0, recordUs = 0, snapshotUs = 0, replayUs = 0;
+    uint64_t frames = 0;
+    Census census;
+    int64_t sinkPaths = 0, sinkPaints = 0, sinkShaders = 0, sinkBuffers = 0,
+            sinkImages = 0;
+};
+
+void row(const char* riv, const char* phase, const char* metric, double value)
+{
+    printf("MEASURE,%s,%s,%s,%.6f\n", riv, phase, metric, value);
+}
+
+void emit(const char* riv, const char* name, const Phase& p)
+{
+    if (p.frames == 0)
+    {
+        return;
+    }
+    double n = static_cast<double>(p.frames);
+    row(riv, name, "frames", n);
+    row(riv, name, "advance_us", p.advanceUs / n);
+    row(riv, name, "record_us", p.recordUs / n);
+    row(riv, name, "snapshot_us", p.snapshotUs / n);
+    row(riv, name, "replay_us", p.replayUs / n);
+    row(riv, name, "cmd_bytes", p.census.commandBytes / n);
+    row(riv, name, "blob_bytes", p.census.blobBytes / n);
+    row(riv,
+        name,
+        "stream_bytes",
+        (p.census.commandBytes + p.census.blobBytes) / n);
+    row(riv, name, "geom_bytes", p.census.geomBytes / n);
+    row(riv, name, "sink_paths", p.sinkPaths / n);
+    row(riv, name, "sink_paints", p.sinkPaints / n);
+    row(riv, name, "sink_shaders", p.sinkShaders / n);
+    row(riv, name, "sink_buffers", p.sinkBuffers / n);
+    row(riv, name, "sink_images", p.sinkImages / n);
+    for (size_t c = 0; c < kNumCmds; c++)
+    {
+        if (p.census.count[c] != 0)
+        {
+            std::string m = std::string("op_") + cmdName(c);
+            row(riv, name, m.c_str(), p.census.count[c] / n);
+        }
+    }
+}
+
+void measureRiv(const std::string& name, int frames, int warmup)
+{
+    std::string path = std::string(kRivDir) + name;
+    FILE* fp = fopen(path.c_str(), "rb");
+    if (fp == nullptr)
+    {
+        printf("MEASURE_SKIP,%s,missing\n", name.c_str());
+        return;
+    }
+    fclose(fp);
+
+    DeferredSession session(nullptr);
+    auto file = ReadRiveFile(path.c_str(), &session);
+    if (file == nullptr)
+    {
+        printf("MEASURE_SKIP,%s,undecodable\n", name.c_str());
+        return;
+    }
+    auto artboard = file->artboardDefault();
+    if (artboard == nullptr)
+    {
+        printf("MEASURE_SKIP,%s,no_artboard\n", name.c_str());
+        return;
+    }
+    auto scene = artboard->defaultScene();
+
+    MSink sink;
+    DeferredReplayer replayer;
+    Phase first, transient, steady;
+    int64_t retainedAtSteady = -3;
+    uint32_t dropped = 0;
+
+    for (int frame = 0; frame < frames; frame++)
+    {
+        Phase* p = frame == 0 ? &first : frame < warmup ? &transient : &steady;
+        float seconds = frame == 0 ? 0.f : 1.f / 60.f;
+
+        auto t0 = std::chrono::steady_clock::now();
+        if (scene != nullptr)
+        {
+            scene->advanceAndApply(seconds);
+        }
+        else
+        {
+            artboard->advance(seconds);
+        }
+        auto t1 = std::chrono::steady_clock::now();
+        Renderer* renderer = session.screenRenderer();
+        renderer->save();
+        if (scene != nullptr)
+        {
+            scene->draw(renderer);
+        }
+        else
+        {
+            artboard->draw(renderer);
+        }
+        renderer->restore();
+        auto t2 = std::chrono::steady_clock::now();
+
+        p->census.add(session.commandBuffer());
+
+        DeferredFrame snapshot = snapshotFrame(session);
+        session.resetFrame();
+        auto t3 = std::chrono::steady_clock::now();
+
+        MFactory& f = sink.f;
+        int paths = f.paths, paints = f.paints, shaders = f.shaders,
+            buffers = f.buffers, images = f.images;
+        replayer.replayFrame(snapshot, sink);
+        auto t4 = std::chrono::steady_clock::now();
+        dropped += replayer.droppedDraws();
+
+        auto us = [](auto a, auto b) {
+            return std::chrono::duration<double, std::micro>(b - a).count();
+        };
+        p->advanceUs += us(t0, t1);
+        p->recordUs += us(t1, t2);
+        p->snapshotUs += us(t2, t3);
+        p->replayUs += us(t3, t4);
+        p->frames++;
+        p->sinkPaths += f.paths - paths;
+        p->sinkPaints += f.paints - paints;
+        p->sinkShaders += f.shaders - shaders;
+        p->sinkBuffers += f.buffers - buffers;
+        p->sinkImages += f.images - images;
+
+        if (frame == frames - 1)
+        {
+            retainedAtSteady = retainedGeometry();
+        }
+    }
+
+    emit(name.c_str(), "first", first);
+    emit(name.c_str(), "transient", transient);
+    emit(name.c_str(), "steady", steady);
+    row(name.c_str(),
+        "run",
+        "retained_geometry_bytes",
+        static_cast<double>(retainedAtSteady));
+    row(name.c_str(), "run", "dropped_draws", static_cast<double>(dropped));
+    row(name.c_str(),
+        "run",
+        "stream_overrun",
+        first.census.overrun || transient.census.overrun ||
+                steady.census.overrun
+            ? 1
+            : 0);
+
+    // Consumer resident tables after the run: how far the dense vectors had
+    // to grow, which is what process wide ids trade against.
+    const ResourceTable& t = replayer.table();
+    auto live = [](const auto& r) {
+        size_t n = 0;
+        for (const auto& o : r.objects)
+        {
+            n += o != nullptr ? 1 : 0;
+        }
+        return static_cast<double>(n);
+    };
+    row(name.c_str(),
+        "resident",
+        "path_slots",
+        static_cast<double>(t.paths.objects.size()));
+    row(name.c_str(), "resident", "path_live", live(t.paths));
+    row(name.c_str(),
+        "resident",
+        "paint_slots",
+        static_cast<double>(t.paints.objects.size()));
+    row(name.c_str(), "resident", "paint_live", live(t.paints));
+    row(name.c_str(),
+        "resident",
+        "shader_slots",
+        static_cast<double>(t.shaders.objects.size()));
+    row(name.c_str(), "resident", "shader_live", live(t.shaders));
+    row(name.c_str(),
+        "resident",
+        "image_slots",
+        static_cast<double>(t.images.objects.size()));
+    row(name.c_str(),
+        "resident",
+        "buffer_slots",
+        static_cast<double>(t.buffers.objects.size()));
+    row(name.c_str(), "resident", "buffer_live", live(t.buffers));
+    // Bytes the dense vectors themselves occupy, ignoring the objects.
+    double slotBytes = static_cast<double>(t.paths.objects.size()) *
+                           (sizeof(rcp<RenderPath>) + 2 * sizeof(uint32_t)) +
+                       static_cast<double>(t.paints.objects.size()) *
+                           (sizeof(rcp<RenderPaint>) + 2 * sizeof(uint32_t)) +
+                       static_cast<double>(t.shaders.objects.size()) *
+                           (sizeof(rcp<RenderShader>) + 2 * sizeof(uint32_t)) +
+                       static_cast<double>(t.images.objects.size()) *
+                           (sizeof(rcp<RenderImage>) + 2 * sizeof(uint32_t)) +
+                       static_cast<double>(t.buffers.objects.size()) *
+                           (sizeof(rcp<RenderBuffer>) + 2 * sizeof(uint32_t));
+    row(name.c_str(), "resident", "slot_vector_bytes", slotBytes);
+}
+} // namespace
+
+TEST_CASE("deferred measure", "[.][deferred_measure]")
+{
+    int frames = envInt("RIVE_MEASURE_FRAMES", 3000);
+    int warmup = envInt("RIVE_MEASURE_WARMUP", 300);
+    printf("MEASURE_CONFIG,frames,%d,warmup,%d\n", frames, warmup);
+    printf("MEASURE_CONFIG,retained_instrumented,%d\n",
+           HasRetained<DeferredRenderPath>::value ? 1 : 0);
+    for (const std::string& name : corpus())
+    {
+        measureRiv(name, frames, warmup);
+    }
+}
+
+// Several sessions live at once, each drawing its own riv, so the consumer
+// resident vectors size to the process wide id high water rather than to any
+// one session's own resources.
+TEST_CASE("deferred measure concurrent sessions", "[.][deferred_measure]")
+{
+    int sessions = envInt("RIVE_MEASURE_SESSIONS", 8);
+    int frames = std::max(4, envInt("RIVE_MEASURE_FRAMES", 3000) / 100);
+    std::vector<std::string> names = corpus();
+    if (names.empty())
+    {
+        return;
+    }
+
+    struct Live
+    {
+        std::unique_ptr<DeferredSession> session;
+        rcp<File> file;
+        std::unique_ptr<ArtboardInstance> artboard;
+        std::unique_ptr<Scene> scene;
+        MSink sink;
+        DeferredReplayer replayer;
+    };
+    std::vector<std::unique_ptr<Live>> live;
+    for (int i = 0; i < sessions; i++)
+    {
+        const std::string& name = names[i % names.size()];
+        std::string path = std::string(kRivDir) + name;
+        FILE* fp = fopen(path.c_str(), "rb");
+        if (fp == nullptr)
+        {
+            continue;
+        }
+        fclose(fp);
+        auto l = std::make_unique<Live>();
+        l->session = std::make_unique<DeferredSession>(nullptr);
+        l->file = ReadRiveFile(path.c_str(), l->session.get());
+        if (l->file == nullptr)
+        {
+            continue;
+        }
+        l->artboard = l->file->artboardDefault();
+        if (l->artboard == nullptr)
+        {
+            continue;
+        }
+        l->scene = l->artboard->defaultScene();
+        live.push_back(std::move(l));
+    }
+    printf("MEASURE_CONFIG,concurrent_sessions,%d,frames,%d\n",
+           static_cast<int>(live.size()),
+           frames);
+
+    for (int frame = 0; frame < frames; frame++)
+    {
+        for (auto& l : live)
+        {
+            float seconds = frame == 0 ? 0.f : 1.f / 60.f;
+            if (l->scene != nullptr)
+            {
+                l->scene->advanceAndApply(seconds);
+            }
+            else
+            {
+                l->artboard->advance(seconds);
+            }
+            Renderer* r = l->session->screenRenderer();
+            r->save();
+            if (l->scene != nullptr)
+            {
+                l->scene->draw(r);
+            }
+            else
+            {
+                l->artboard->draw(r);
+            }
+            r->restore();
+            DeferredFrame snapshot = snapshotFrame(*l->session);
+            l->session->resetFrame();
+            l->replayer.replayFrame(snapshot, l->sink);
+        }
+    }
+
+    double totalSlots = 0, totalLive = 0, totalBytes = 0;
+    for (size_t i = 0; i < live.size(); i++)
+    {
+        const ResourceTable& t = live[i]->replayer.table();
+        auto liveCount = [](const auto& r) {
+            size_t n = 0;
+            for (const auto& o : r.objects)
+            {
+                n += o != nullptr ? 1 : 0;
+            }
+            return static_cast<double>(n);
+        };
+        double slots = static_cast<double>(
+            t.paths.objects.size() + t.paints.objects.size() +
+            t.shaders.objects.size() + t.images.objects.size() +
+            t.buffers.objects.size());
+        double used = liveCount(t.paths) + liveCount(t.paints) +
+                      liveCount(t.shaders) + liveCount(t.images) +
+                      liveCount(t.buffers);
+        double bytes = slots * (sizeof(rcp<RenderPath>) + 2 * sizeof(uint32_t));
+        printf("MEASURE,session_%d,resident,slots,%.0f\n",
+               static_cast<int>(i),
+               slots);
+        printf("MEASURE,session_%d,resident,live,%.0f\n",
+               static_cast<int>(i),
+               used);
+        totalSlots += slots;
+        totalLive += used;
+        totalBytes += bytes;
+    }
+    printf("MEASURE,all_sessions,resident,slots,%.0f\n", totalSlots);
+    printf("MEASURE,all_sessions,resident,live,%.0f\n", totalLive);
+    printf("MEASURE,all_sessions,resident,slot_vector_bytes,%.0f\n",
+           totalBytes);
+}
diff --git a/tests/unit_tests/renderer/deferred_replay_order_test.cpp b/tests/unit_tests/renderer/deferred_replay_order_test.cpp
new file mode 100644
index 0000000..0b92c39
--- /dev/null
+++ b/tests/unit_tests/renderer/deferred_replay_order_test.cpp
@@ -0,0 +1,306 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Segment replay reorders canvas brackets before screen gaps. The replayer
+// hoists creates into a record order pass and defers destroys to a trailing
+// pass so reordering cannot break mint order or free a slot early.
+
+#include "rive/renderer/cmd/deferred_render_factory.hpp"
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "deferred_test_sink.hpp"
+
+#include <catch.hpp>
+
+using namespace rive;
+using rive::cmd::DeferredSegment;
+using Target = rive::cmd::DeferredSegment::Target;
+using deferred_test::TestSink;
+
+namespace
+{
+// Counts paint creations so tests can assert version materialization.
+class CountingFactory : public SerializingFactory
+{
+public:
+    int paintCount = 0;
+    rcp<RenderPaint> makeRenderPaint() override
+    {
+        paintCount++;
+        return SerializingFactory::makeRenderPaint();
+    }
+};
+} // namespace
+
+using CountingSink = deferred_test::TestSinkT<CountingFactory>;
+
+TEST_CASE("a create inside a canvas bracket replays in mint order",
+          "[deferred][replay][segment]")
+{
+    cmd::DeferredFactory factory;
+    auto& buffer = factory.commandBuffer();
+    auto renderer = factory.makeRenderer();
+
+    auto paint = factory.makeRenderPaint();
+    auto p1 = factory.makeEmptyRenderPath(); // path id 0, screen phase
+
+    // Path id 1 is minted inside the canvas bracket.
+    uint32_t bracketBegin = static_cast<uint32_t>(buffer.commandBytes().size());
+    constexpr cmd::RenderHandle kCanvas = 7 | cmd::kCanvasHandleFlag;
+    buffer.append(static_cast<uint8_t>(cmd::RenderCmd::canvasContentBegin),
+                  cmd::CanvasContentPOD{kCanvas, 0xFF000000});
+    auto p2 = factory.makeEmptyRenderPath(); // path id 1, canvas phase
+    renderer->drawPath(p2.get(), paint.get());
+    buffer.append(static_cast<uint8_t>(cmd::RenderCmd::canvasContentEnd),
+                  cmd::ResIdPOD{kCanvas});
+    uint32_t bracketEnd = static_cast<uint32_t>(buffer.commandBytes().size());
+
+    // The scheduler runs the bracket first, so without the hoisted create
+    // pass path id 1 would replay before id 0 and the screen draws drop.
+    renderer->drawPath(p1.get(), paint.get());
+    renderer->drawPath(p2.get(), paint.get());
+
+    cmd::DeferredFrame frame;
+    auto copy = [](Span<const uint8_t> s) {
+        return std::vector<uint8_t>(s.data(), s.data() + s.size());
+    };
+    frame.commands = copy(buffer.commandBytes());
+    frame.blobs = copy(buffer.blobBytes());
+    frame.segments = {
+        {Target::screen, 0, 0, bracketBegin},
+        {Target::canvas, 7, bracketBegin, bracketEnd},
+        {Target::screen,
+         0,
+         bracketEnd,
+         static_cast<uint32_t>(frame.commands.size())},
+    };
+
+    TestSink sink;
+    cmd::DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+    CHECK(replayer.droppedDraws() == 0);
+}
+
+TEST_CASE("interleaved multi-target drawing splits per-renderer ranges",
+          "[deferred][replay][segment]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto* screen = session.screenRenderer();
+    // Routed canvas recorders like beginCanvasContent hands a script.
+    cmd::DeferredRenderer c1(&session.commandBuffer(),
+                             &session.canvases(),
+                             &session,
+                             1);
+    cmd::DeferredRenderer c2(&session.commandBuffer(),
+                             &session.canvases(),
+                             &session,
+                             2);
+
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+
+    // Attribution is per renderer, so interleaving must record non nested
+    // canvas ranges around the screen gaps.
+    screen->drawPath(path.get(), paint.get());
+    c1.drawPath(path.get(), paint.get());
+    c2.drawPath(path.get(), paint.get());
+    c1.drawPath(path.get(), paint.get());
+    screen->drawPath(path.get(), paint.get());
+    session.closeOpenRange();
+
+    std::vector<DeferredSegment> segs;
+    for (const auto& s : session.recordedSegments())
+    {
+        if (s.target == Target::canvas)
+        {
+            segs.push_back(s);
+        }
+    }
+    REQUIRE(segs.size() == 3);
+    CHECK(segs[0].targetId == 1u);
+    CHECK(segs[1].targetId == 2u);
+    CHECK(segs[2].targetId == 1u);
+    for (size_t i = 1; i < segs.size(); i++)
+    {
+        CHECK(segs[i].begin >= segs[i - 1].end);
+    }
+
+    // Canvas 1's two ranges group into one canvas frame, nothing drops.
+    cmd::DeferredFrame frame;
+    auto copy = [](Span<const uint8_t> s) {
+        return std::vector<uint8_t>(s.data(), s.data() + s.size());
+    };
+    frame.commands = copy(session.commandBuffer().commandBytes());
+    frame.blobs = copy(session.commandBuffer().blobBytes());
+    frame.segments = session.schedulerSegments();
+
+    TestSink sink;
+    cmd::DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+    CHECK(replayer.droppedDraws() == 0);
+}
+
+TEST_CASE("Image:view on a decoded image records an imageView wrap",
+          "[deferred][replay][image]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto view = session.oreContext().recordWrapImageView(42, 64, 64);
+    REQUIRE(view != nullptr);
+
+    // In imageView mode the canvasId field carries the 2D image resource id.
+    const auto& stream = session.oreContext().stream();
+    ore::cmd::OreCommandReader reader(stream.commandBytes(),
+                                      stream.blobBytes());
+    ore::cmd::CommandType type;
+    REQUIRE(reader.next(type));
+    REQUIRE(type == ore::cmd::CommandType::wrapCanvasView);
+    auto pod = reader.read<ore::cmd::WrapCanvasViewPOD>();
+    CHECK(pod.canvasId == 42u);
+    CHECK(pod.mode ==
+          static_cast<uint32_t>(ore::cmd::WrapCanvasViewMode::imageView));
+}
+
+TEST_CASE("a screen-gap destroy does not starve a reordered canvas segment",
+          "[deferred][replay][segment]")
+{
+    cmd::DeferredFactory factory;
+    auto& buffer = factory.commandBuffer();
+    auto renderer = factory.makeRenderer();
+
+    auto paint = factory.makeRenderPaint();
+    auto p1 = factory.makeEmptyRenderPath();
+
+    // The rcp release records a destroy.
+    renderer->drawPath(p1.get(), paint.get());
+    cmd::RenderHandle id = cmd::DeferredRenderPath::idOfPath(p1.get());
+    REQUIRE(id != cmd::kInvalidRenderHandle);
+    p1 = nullptr;
+    buffer.drainDestroys();
+
+    // This bracket is recorded after the destroy but replays before the
+    // screen gap, so the destroy must stay behind its draws.
+    uint32_t bracketBegin = static_cast<uint32_t>(buffer.commandBytes().size());
+    constexpr cmd::RenderHandle kCanvas = 3 | cmd::kCanvasHandleFlag;
+    buffer.append(static_cast<uint8_t>(cmd::RenderCmd::canvasContentBegin),
+                  cmd::CanvasContentPOD{kCanvas, 0xFF000000});
+    auto p2 = factory.makeEmptyRenderPath();
+    renderer->drawPath(p2.get(), paint.get());
+    buffer.append(static_cast<uint8_t>(cmd::RenderCmd::canvasContentEnd),
+                  cmd::ResIdPOD{kCanvas});
+    uint32_t bracketEnd = static_cast<uint32_t>(buffer.commandBytes().size());
+
+    renderer->drawPath(p2.get(), paint.get());
+
+    cmd::DeferredFrame frame;
+    auto copy = [](Span<const uint8_t> s) {
+        return std::vector<uint8_t>(s.data(), s.data() + s.size());
+    };
+    frame.commands = copy(buffer.commandBytes());
+    frame.blobs = copy(buffer.blobBytes());
+    frame.segments = {
+        {Target::screen, 0, 0, bracketBegin},
+        {Target::canvas, 3, bracketBegin, bracketEnd},
+        {Target::screen,
+         0,
+         bracketEnd,
+         static_cast<uint32_t>(frame.commands.size())},
+    };
+
+    TestSink sink;
+    cmd::DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+    CHECK(replayer.droppedDraws() == 0);
+}
+
+TEST_CASE("a paint mutated after a draw keeps the draw's version",
+          "[deferred][replay][version]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto* screen = session.screenRenderer();
+    cmd::DeferredRenderer canvas(&session.commandBuffer(),
+                                 &session.canvases(),
+                                 &session,
+                                 1);
+
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+
+    // Mutations replay in record order ahead of every draw, so the draws pin
+    // the version they saw: red for the canvas draw, blue for the screen one.
+    paint->color(0xFFFF0000);
+    canvas.drawPath(path.get(), paint.get());
+    paint->color(0xFF0000FF);
+    screen->drawPath(path.get(), paint.get());
+    session.closeOpenRange();
+
+    cmd::DeferredFrame frame;
+    auto copy = [](Span<const uint8_t> s) {
+        return std::vector<uint8_t>(s.data(), s.data() + s.size());
+    };
+    frame.commands = copy(session.commandBuffer().commandBytes());
+    frame.blobs = copy(session.commandBuffer().blobBytes());
+    frame.segments = session.schedulerSegments();
+
+    CountingSink sink;
+    cmd::DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+    CHECK(replayer.droppedDraws() == 0);
+    // The bump materialized the red version alongside the live blue paint.
+    CHECK(sink.serializingFactory.paintCount == 2);
+}
+
+TEST_CASE("a paint mutated only before its draws stays one object",
+          "[deferred][replay][version]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto* screen = session.screenRenderer();
+
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    paint->color(0xFF00FF00);
+    paint->thickness(2.0f);
+    screen->drawPath(path.get(), paint.get());
+    screen->drawPath(path.get(), paint.get());
+    session.closeOpenRange();
+
+    cmd::DeferredFrame frame;
+    auto copy = [](Span<const uint8_t> s) {
+        return std::vector<uint8_t>(s.data(), s.data() + s.size());
+    };
+    frame.commands = copy(session.commandBuffer().commandBytes());
+    frame.blobs = copy(session.commandBuffer().blobBytes());
+    frame.segments = session.schedulerSegments();
+
+    CountingSink sink;
+    cmd::DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+    CHECK(replayer.droppedDraws() == 0);
+    CHECK(sink.serializingFactory.paintCount == 1);
+}
+
+TEST_CASE("the first mutation of a new frame reuses the live object",
+          "[deferred][replay][version]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto* screen = session.screenRenderer();
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+
+    CountingSink sink;
+    cmd::DeferredReplayer replayer;
+    auto runFrame = [&](ColorInt color) {
+        paint->color(color);
+        screen->drawPath(path.get(), paint.get());
+        auto frame = cmd::snapshotFrame(session);
+        session.resetFrame();
+        replayer.replayFrame(frame, sink);
+        CHECK(replayer.droppedDraws() == 0);
+    };
+    // Animated content mutates every frame; the resident paint must be
+    // reused in place, not reallocated per frame.
+    runFrame(0xFFFF0000);
+    runFrame(0xFF00FF00);
+    runFrame(0xFF0000FF);
+    CHECK(sink.serializingFactory.paintCount == 1);
+}
diff --git a/tests/unit_tests/renderer/deferred_segment_test.cpp b/tests/unit_tests/renderer/deferred_segment_test.cpp
new file mode 100644
index 0000000..5cc9adc
--- /dev/null
+++ b/tests/unit_tests/renderer/deferred_segment_test.cpp
@@ -0,0 +1,209 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Screen segments are the 2D stream regions outside canvas brackets, each
+// naming the render target its draws belong to. Recording only, no GPU.
+
+#include "rive/renderer/cmd/deferred_render_factory.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+
+#include <catch.hpp>
+
+using namespace rive;
+using rive::cmd::DeferredSegment;
+using Target = rive::cmd::DeferredSegment::Target;
+
+namespace
+{
+// A canvas recorder like the one beginCanvasContent hands a script.
+std::unique_ptr<cmd::DeferredRenderer> canvasRecorder(
+    cmd::DeferredSession& session,
+    uint64_t canvasId)
+{
+    return std::make_unique<cmd::DeferredRenderer>(&session.commandBuffer(),
+                                                   &session.canvases(),
+                                                   &session,
+                                                   canvasId);
+}
+} // namespace
+
+TEST_CASE("a screen only frame is one screen segment", "[ore][cmd][segment]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    uint32_t afterCreates =
+        static_cast<uint32_t>(session.commandBuffer().commandBytes().size());
+    session.screenRenderer()->drawPath(path.get(), paint.get());
+    session.closeOpenRange();
+
+    auto all = session.schedulerSegments();
+    REQUIRE(all.size() == 1);
+    CHECK(all[0].target == Target::screen);
+    CHECK(all[0].targetId == 0u);
+    CHECK(all[0].begin == afterCreates);
+    CHECK(all[0].end == session.commandBuffer().commandBytes().size());
+}
+
+TEST_CASE("bytes recorded before any target draws claim no segment",
+          "[ore][cmd][segment]")
+{
+    cmd::DeferredSession session(nullptr);
+    // Creates and drained destroys replay from the whole stream, so they need
+    // no segment; giving them one would open a target's frame in a frame
+    // where only other targets drew.
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    CHECK(session.commandBuffer().commandBytes().size() > 0);
+    session.closeOpenRange();
+    CHECK(session.schedulerSegments().empty());
+}
+
+TEST_CASE("a canvas bracket carves leading and trailing screen segments",
+          "[ore][cmd][segment]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto canvas = canvasRecorder(session, 1);
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    uint32_t afterCreates =
+        static_cast<uint32_t>(session.commandBuffer().commandBytes().size());
+
+    session.screenRenderer()->drawPath(path.get(), paint.get());
+    canvas->drawPath(path.get(), paint.get());
+    session.screenRenderer()->drawPath(path.get(), paint.get());
+    session.closeOpenRange();
+
+    auto all = session.schedulerSegments();
+    REQUIRE(all.size() == 3);
+    CHECK(all[0].target == Target::screen);
+    CHECK(all[0].begin == afterCreates);
+    CHECK(all[1].target == Target::canvas);
+    CHECK(all[1].targetId == 1u);
+    CHECK(all[1].begin == all[0].end);
+    CHECK(all[2].target == Target::screen);
+    CHECK(all[2].begin == all[1].end);
+    CHECK(all[2].end == session.commandBuffer().commandBytes().size());
+}
+
+TEST_CASE("a canvas bracket at offset 0 has no leading screen segment",
+          "[ore][cmd][segment]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto canvas = canvasRecorder(session, 1);
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+
+    // Creates land in the stream before the first draw, so open the canvas
+    // range from the very start by drawing into it first.
+    session.closeOpenRange();
+    uint32_t start =
+        static_cast<uint32_t>(session.commandBuffer().commandBytes().size());
+    canvas->drawPath(path.get(), paint.get());
+    session.screenRenderer()->drawPath(path.get(), paint.get());
+    session.closeOpenRange();
+
+    std::vector<DeferredSegment> after;
+    for (const auto& s : session.schedulerSegments())
+    {
+        if (s.begin >= start)
+        {
+            after.push_back(s);
+        }
+    }
+    REQUIRE(after.size() == 2);
+    CHECK(after[0].target == Target::canvas);
+    CHECK(after[0].begin == start);
+    CHECK(after[1].target == Target::screen);
+    CHECK(after[1].begin == after[0].end);
+}
+
+TEST_CASE("each screen target gets its own segments", "[ore][cmd][segment]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+
+    // Two widgets painting in one frame, interleaved as Flutter would.
+    session.screenRenderer(0)->drawPath(path.get(), paint.get());
+    session.screenRenderer(7)->drawPath(path.get(), paint.get());
+    session.screenRenderer(0)->drawPath(path.get(), paint.get());
+    session.closeOpenRange();
+
+    auto all = session.schedulerSegments();
+    REQUIRE(all.size() == 3);
+    CHECK(all[0].targetId == 0u);
+    CHECK(all[1].targetId == 7u);
+    CHECK(all[2].targetId == 0u);
+    for (const auto& s : all)
+    {
+        CHECK(s.target == Target::screen);
+    }
+    for (size_t i = 1; i < all.size(); i++)
+    {
+        CHECK(all[i].begin == all[i - 1].end);
+    }
+}
+
+TEST_CASE("a canvas hands the stream back to the screen it interrupted",
+          "[ore][cmd][segment]")
+{
+    cmd::DeferredSession session(nullptr);
+    auto canvas = canvasRecorder(session, 1);
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+
+    session.screenRenderer(7)->drawPath(path.get(), paint.get());
+    canvas->drawPath(path.get(), paint.get());
+    session.closeOpenRange(); // closes the canvas range, as a snapshot would
+    // Creates recorded after that belong to target 7, which was drawing, not
+    // to the default screen, which drew nothing this frame.
+    auto later = session.makeEmptyRenderPath();
+    session.closeOpenRange();
+
+    auto all = session.schedulerSegments();
+    REQUIRE(all.size() == 3);
+    CHECK(all[0].target == Target::screen);
+    CHECK(all[0].targetId == 7u);
+    CHECK(all[1].target == Target::canvas);
+    CHECK(all[2].target == Target::screen);
+    CHECK(all[2].targetId == 7u);
+}
+
+TEST_CASE("a session frame closes when the last target finishes",
+          "[ore][cmd][segment]")
+{
+    cmd::DeferredSession session(nullptr);
+    uint64_t a = session.acquireScreenTarget();
+    uint64_t b = session.acquireScreenTarget();
+    CHECK(a == 0u);
+    CHECK(b == 1u);
+
+    // Sequential painting: each target opens and closes its own window.
+    session.beginTargetFrame(a);
+    CHECK(session.endTargetFrame(a));
+    session.beginTargetFrame(b);
+    CHECK(session.endTargetFrame(b));
+
+    // Nested painting: the inner finish must not end the session's frame,
+    // resetting the stream under a target still recording.
+    session.beginTargetFrame(a);
+    session.beginTargetFrame(b);
+    CHECK(!session.endTargetFrame(b));
+    CHECK(session.endTargetFrame(a));
+
+    // A released target's id and recorder are reclaimed.
+    session.releaseScreenTarget(a);
+    CHECK(session.acquireScreenTarget() == a);
+}
+
+TEST_CASE("the screen recorder for a target survives resetFrame",
+          "[ore][cmd][segment]")
+{
+    cmd::DeferredSession session(nullptr);
+    // FFI hosts take this raw and keep drawing through it across frames.
+    Renderer* first = session.screenRenderer(3);
+    session.resetFrame();
+    CHECK(session.screenRenderer(3) == first);
+}
diff --git a/tests/unit_tests/renderer/deferred_test_sink.hpp b/tests/unit_tests/renderer/deferred_test_sink.hpp
new file mode 100644
index 0000000..0ba1601
--- /dev/null
+++ b/tests/unit_tests/renderer/deferred_test_sink.hpp
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#include "utils/serializing_factory.hpp"
+
+#include <memory>
+#include <unordered_map>
+
+namespace deferred_test
+{
+// GPU free sink over a serializing factory. Canvas content is unsupported so
+// those draws drop without counting. FactoryT may subclass SerializingFactory
+// to observe replay side effects.
+template <typename FactoryT = rive::SerializingFactory>
+class TestSinkT : public rive::cmd::DeferredFrameSink
+{
+public:
+    FactoryT serializingFactory;
+
+    rive::Factory* factory() override { return &serializingFactory; }
+    rive::ore::Context* oreContext() override { return nullptr; }
+    // A serialized frame per target, so a multi target replay is legible as
+    // separate frames instead of one merged stream.
+    rive::Renderer* beginScreenFrame(uint64_t target) override
+    {
+        serializingFactory.frameSize(256, 256);
+        serializingFactory.addFrame();
+        auto& screen = m_screens[target];
+        screen = serializingFactory.makeRenderer();
+        return screen.get();
+    }
+
+    size_t openedTargets() const { return m_screens.size(); }
+
+private:
+    std::unordered_map<uint64_t, std::unique_ptr<rive::Renderer>> m_screens;
+};
+
+using TestSink = TestSinkT<>;
+} // namespace deferred_test
diff --git a/tests/unit_tests/renderer/foreign_image_registry_test.cpp b/tests/unit_tests/renderer/foreign_image_registry_test.cpp
new file mode 100644
index 0000000..fd32bb8
--- /dev/null
+++ b/tests/unit_tests/renderer/foreign_image_registry_test.cpp
@@ -0,0 +1,219 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// ForeignImageRegistry is the cross session image route. A RenderImage the
+// session did not decode is not in its id space, so the registry retains the
+// object and the frame snapshot carries the rcp: whoever replays resolves the
+// image itself rather than an id some other table has to agree about.
+//
+// These cases drive it the way a host does, through DeferredRenderer::drawImage
+// on a session's own recorder, and read the resolved object back off replay.
+
+#include "rive/renderer/cmd/deferred_render_factory.hpp"
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "deferred_test_sink.hpp"
+
+#include <catch.hpp>
+
+using namespace rive;
+
+namespace
+{
+// A RenderImage nothing decoded through a session, which is what makes it
+// foreign: lite_rtti_cast to DeferredRenderImage fails and the recorder falls
+// through to the registry.
+class ForeignImage : public RenderImage
+{
+public:
+    ForeignImage(int tag, bool* destroyed) : m_tag(tag), m_destroyed(destroyed)
+    {
+        m_Width = 4;
+        m_Height = 4;
+    }
+
+    ~ForeignImage() override
+    {
+        if (m_destroyed != nullptr)
+        {
+            *m_destroyed = true;
+        }
+    }
+
+    int tag() const { return m_tag; }
+
+private:
+    int m_tag;
+    bool* m_destroyed;
+};
+
+// Records the image object each replayed draw resolved to, which is the only
+// way to tell a right resolution from a wrong one that also draws.
+class ImageRecorder : public Renderer
+{
+public:
+    std::vector<const RenderImage*> drawn;
+
+    void drawImage(const RenderImage* image,
+                   ImageSampler,
+                   BlendMode,
+                   float) override
+    {
+        drawn.push_back(image);
+    }
+
+    void save() override {}
+    void restore() override {}
+    void transform(const Mat2D&) override {}
+    void drawPath(RenderPath*, RenderPaint*) override {}
+    void clipPath(RenderPath*) override {}
+    void drawImageMesh(const RenderImage* image,
+                       ImageSampler,
+                       rcp<RenderBuffer>,
+                       rcp<RenderBuffer>,
+                       rcp<RenderBuffer>,
+                       uint32_t,
+                       uint32_t,
+                       BlendMode,
+                       float) override
+    {
+        drawn.push_back(image);
+    }
+    void modulateOpacity(float) override {}
+};
+
+class ImageSink : public deferred_test::TestSink
+{
+public:
+    ImageRecorder recorder;
+
+    Renderer* beginScreenFrame(uint64_t) override { return &recorder; }
+};
+
+void drawForeign(cmd::DeferredSession& session, RenderImage* image)
+{
+    session.screenRenderer()->drawImage(image,
+                                        ImageSampler::LinearClamp(),
+                                        BlendMode::srcOver,
+                                        1.0f);
+}
+
+// Replays one recorded frame and reports what its draws resolved to.
+std::vector<const RenderImage*> replayed(const cmd::DeferredFrame& frame)
+{
+    ImageSink sink;
+    cmd::DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+    CHECK(replayer.droppedDraws() == 0);
+    return sink.recorder.drawn;
+}
+
+// Same, for the inline form, which resolves against the live registry instead
+// of the snapshot's copy. Both routes are shipped, so both are covered.
+std::vector<const RenderImage*> replayedInline(cmd::DeferredSession& session)
+{
+    ImageSink sink;
+    cmd::DeferredReplayer replayer;
+    replayer.replayFrame(session, sink);
+    CHECK(replayer.droppedDraws() == 0);
+    return sink.recorder.drawn;
+}
+} // namespace
+
+TEST_CASE("a foreign image resolves in a session that never decoded it",
+          "[deferred][foreign_image]")
+{
+    bool destroyed = false;
+    rcp<ForeignImage> image(new ForeignImage(1, &destroyed));
+
+    // Two sessions with nothing shared between them: separate id spaces,
+    // separate registries, separate streams.
+    cmd::DeferredSession first(nullptr);
+    cmd::DeferredSession second(nullptr);
+
+    drawForeign(first, image.get());
+    auto firstDrawn = replayed(cmd::takeFrame(first));
+
+    drawForeign(second, image.get());
+    auto secondLive = replayedInline(second);
+    auto secondDrawn = replayed(cmd::takeFrame(second));
+
+    REQUIRE(firstDrawn.size() == 1);
+    REQUIRE(secondLive.size() == 1);
+    REQUIRE(secondDrawn.size() == 1);
+    CHECK(firstDrawn[0] == image.get());
+    CHECK(secondLive[0] == image.get());
+    CHECK(secondDrawn[0] == image.get());
+    CHECK_FALSE(destroyed);
+}
+
+TEST_CASE("two sessions numbering the same images oppositely each resolve "
+          "their own",
+          "[deferred][foreign_image]")
+{
+    rcp<ForeignImage> a(new ForeignImage(1, nullptr));
+    rcp<ForeignImage> b(new ForeignImage(2, nullptr));
+
+    // Registration order sets the unflagged id, so the two sessions give the
+    // same pair of images opposite ids. Resolving through anything id keyed
+    // and shared crosses them, and both draws still land.
+    cmd::DeferredSession forward(nullptr);
+    drawForeign(forward, a.get());
+    drawForeign(forward, b.get());
+    auto forwardLive = replayedInline(forward);
+    auto forwardDrawn = replayed(cmd::takeFrame(forward));
+
+    cmd::DeferredSession reverse(nullptr);
+    drawForeign(reverse, b.get());
+    drawForeign(reverse, a.get());
+    auto reverseLive = replayedInline(reverse);
+    auto reverseDrawn = replayed(cmd::takeFrame(reverse));
+
+    auto tags = [](const std::vector<const RenderImage*>& drawn) {
+        std::vector<int> out;
+        for (auto* image : drawn)
+        {
+            out.push_back(static_cast<const ForeignImage*>(image)->tag());
+        }
+        return out;
+    };
+
+    CHECK(tags(forwardLive) == std::vector<int>{1, 2});
+    CHECK(tags(forwardDrawn) == std::vector<int>{1, 2});
+    CHECK(tags(reverseLive) == std::vector<int>{2, 1});
+    CHECK(tags(reverseDrawn) == std::vector<int>{2, 1});
+}
+
+TEST_CASE("a snapshot holds a foreign image past the frame and past its "
+          "caller",
+          "[deferred][foreign_image]")
+{
+    bool destroyed = false;
+    auto* raw = new ForeignImage(3, &destroyed);
+    rcp<ForeignImage> image(raw);
+
+    cmd::DeferredSession session(nullptr);
+    drawForeign(session, raw);
+
+    // takeFrame copies the retained images out and clears the registry, so
+    // after the caller lets go the snapshot is the only owner left. A registry
+    // that recorded the pointer without retaining it leaves replay a dangling
+    // one, and replay would still draw.
+    cmd::DeferredFrame frame = cmd::takeFrame(session);
+    // Checked before the caller's reference goes away: a registry that only
+    // recorded the pointer would leave the object already dead here, and the
+    // release below would be a use after free rather than an assertion.
+    REQUIRE(raw->debugging_refcnt() > 1);
+    image = nullptr;
+    REQUIRE_FALSE(destroyed);
+
+    auto drawn = replayed(frame);
+    REQUIRE(drawn.size() == 1);
+    CHECK(drawn[0] == raw);
+    CHECK(static_cast<const ForeignImage*>(drawn[0])->tag() == 3);
+    CHECK_FALSE(destroyed);
+
+    frame = cmd::DeferredFrame{};
+    CHECK(destroyed);
+}
diff --git a/tests/unit_tests/renderer/gpu_census_test.cpp b/tests/unit_tests/renderer/gpu_census_test.cpp
new file mode 100644
index 0000000..709e479
--- /dev/null
+++ b/tests/unit_tests/renderer/gpu_census_test.cpp
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// The GPU census walks the replayer's resident tables. What it has to get
+// right to be usable as evidence: it counts what is live and not what was
+// freed, it scales with the resources actually resident, and it is a level so
+// reading it twice gives the same answer.
+
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "rive/renderer/cmd/gpu_census.hpp"
+#include "deferred_test_sink.hpp"
+
+#include <catch.hpp>
+
+using namespace rive;
+using deferred_test::TestSink;
+
+namespace
+{
+// Replay one session frame and hand back what stayed resident.
+cmd::GpuCensus replayAndCensus(cmd::DeferredSession& session,
+                               cmd::DeferredReplayer& replayer,
+                               TestSink& sink)
+{
+    cmd::DeferredFrame frame = cmd::takeFrame(session);
+    replayer.replayFrame(frame, sink);
+    return replayer.gpuCensus();
+}
+} // namespace
+
+TEST_CASE("the census counts what replay left resident", "[deferred][census]")
+{
+    cmd::DeferredSession session(nullptr);
+    cmd::DeferredReplayer replayer;
+    TestSink sink;
+
+    auto* screen = session.screenRenderer();
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    screen->drawPath(path.get(), paint.get());
+
+    cmd::GpuCensus c = replayAndCensus(session, replayer, sink);
+    CHECK(c.paths == 1);
+    CHECK(c.paints == 1);
+    // Nothing sized was recorded, so the byte total has to be zero rather than
+    // some incidental nonzero from the count tables.
+    CHECK(c.totalBytes() == 0);
+
+    // A level, not a running total: the same walk twice is the same answer.
+    CHECK(replayer.gpuCensus().totalBytes() == c.totalBytes());
+    CHECK(replayer.gpuCensus().liveObjects() == c.liveObjects());
+}
+
+TEST_CASE("census bytes scale with the resources resident",
+          "[deferred][census]")
+{
+    cmd::DeferredSession session(nullptr);
+    cmd::DeferredReplayer replayer;
+    TestSink sink;
+
+    auto* screen = session.screenRenderer();
+    auto paint = session.makeRenderPaint();
+    auto buffer = session.makeRenderBuffer(RenderBufferType::vertex,
+                                           RenderBufferFlags::none,
+                                           1024);
+    // Touch it so the draw keeps the recording honest about a live buffer.
+    auto path = session.makeEmptyRenderPath();
+    screen->drawPath(path.get(), paint.get());
+
+    cmd::GpuCensus one = replayAndCensus(session, replayer, sink);
+    CHECK(one.buffers == 1);
+    CHECK(one.bufferBytes == 1024);
+    CHECK(one.totalBytes() == 1024);
+
+    // A second buffer of the same size doubles the sized total, and the
+    // unsized counts stay put.
+    auto buffer2 = session.makeRenderBuffer(RenderBufferType::vertex,
+                                            RenderBufferFlags::none,
+                                            1024);
+    screen->drawPath(path.get(), paint.get());
+    cmd::GpuCensus two = replayAndCensus(session, replayer, sink);
+    CHECK(two.buffers == 2);
+    CHECK(two.bufferBytes == 2048);
+    CHECK(two.paths == one.paths);
+    CHECK(two.paints == one.paints);
+}
+
+TEST_CASE("a destroyed resource leaves the census but keeps its slot",
+          "[deferred][census]")
+{
+    cmd::DeferredSession session(nullptr);
+    cmd::DeferredReplayer replayer;
+    TestSink sink;
+
+    auto* screen = session.screenRenderer();
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    {
+        auto doomed = session.makeRenderBuffer(RenderBufferType::vertex,
+                                               RenderBufferFlags::none,
+                                               4096);
+        screen->drawPath(path.get(), paint.get());
+        cmd::GpuCensus live = replayAndCensus(session, replayer, sink);
+        CHECK(live.bufferBytes == 4096);
+        CHECK(live.slots2d >= live.liveObjects());
+    }
+    // The rcp died, so the next frame carries the destroy record.
+    session.commandBuffer().drainDestroys();
+    screen->drawPath(path.get(), paint.get());
+    cmd::GpuCensus after = replayAndCensus(session, replayer, sink);
+    CHECK(after.buffers == 0);
+    CHECK(after.bufferBytes == 0);
+    // The tables never compact, so the freed slot is still counted as minted.
+    CHECK(after.slots2d >= 1);
+}
+
+TEST_CASE("reset empties the census", "[deferred][census]")
+{
+    cmd::DeferredSession session(nullptr);
+    cmd::DeferredReplayer replayer;
+    TestSink sink;
+
+    auto* screen = session.screenRenderer();
+    auto paint = session.makeRenderPaint();
+    auto path = session.makeEmptyRenderPath();
+    auto buffer = session.makeRenderBuffer(RenderBufferType::vertex,
+                                           RenderBufferFlags::none,
+                                           2048);
+    screen->drawPath(path.get(), paint.get());
+    CHECK(replayAndCensus(session, replayer, sink).totalBytes() == 2048);
+
+    replayer.reset();
+    cmd::GpuCensus empty = replayer.gpuCensus();
+    CHECK(empty.totalBytes() == 0);
+    CHECK(empty.liveObjects() == 0);
+    CHECK(empty.slots2d == 0);
+    CHECK(empty.slotsOre == 0);
+}
+
+TEST_CASE("ore texture sizing covers mips, layers and samples",
+          "[deferred][census]")
+{
+    // No GPU here, so size the arithmetic directly against the format table
+    // rather than through a real texture.
+    using rive::ore::TextureFormat;
+    CHECK(ore::textureFormatBytesPerTexel(TextureFormat::rgba8unorm) == 4);
+    CHECK(ore::textureFormatBytesPerTexel(TextureFormat::r8unorm) == 1);
+    // rgba32float is 16 bytes, so a 4x4 single level is 256.
+    CHECK(ore::textureFormatBytesPerTexel(TextureFormat::rgba32float) == 16);
+}
diff --git a/tests/unit_tests/renderer/ore_command_buffer_test.cpp b/tests/unit_tests/renderer/ore_command_buffer_test.cpp
new file mode 100644
index 0000000..5f94d3a
--- /dev/null
+++ b/tests/unit_tests/renderer/ore_command_buffer_test.cpp
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Confirms every command and upload payload round trips byte for byte through
+// OreCommandBuffer. No GPU needed.
+
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+
+#include <catch.hpp>
+#include <cstring>
+#include <vector>
+
+using namespace rive::ore;
+using namespace rive::ore::cmd;
+using rive::Span;
+
+TEST_CASE("ore command stream round-trips through the reader", "[ore][cmd]")
+{
+    OreCommandBuffer buf;
+
+    BeginRenderPassCmd begin{};
+    begin.colorCount = 1;
+    begin.colors[0].view = 0;
+    begin.colors[0].resolveTarget = kInvalidHandle;
+    begin.colors[0].loadOp = LoadOp::clear;
+    begin.colors[0].storeOp = StoreOp::store;
+    begin.colors[0].clearR = 0.25f;
+    begin.colors[0].clearG = 0.5f;
+    begin.colors[0].clearB = 0.75f;
+    begin.colors[0].clearA = 1.0f;
+    begin.depthStencil.view = kInvalidHandle;
+    buf.append(CommandType::beginRenderPass, begin);
+
+    buf.append(CommandType::setPipeline, SetPipelineCmd{7});
+    buf.append(CommandType::setVertexBuffer, SetVertexBufferCmd{0, 3, 16});
+    buf.append(CommandType::draw, DrawCmd{6, 2, 1, 0});
+    buf.appendOpcode(CommandType::finish);
+
+    OreCommandReader r(buf.commandBytes(), buf.blobBytes());
+    CommandType t;
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::beginRenderPass);
+    auto b = r.read<BeginRenderPassCmd>();
+    CHECK(b.colorCount == 1);
+    CHECK(b.colors[0].view == 0u);
+    CHECK(b.colors[0].resolveTarget == kInvalidHandle);
+    CHECK(b.colors[0].loadOp == LoadOp::clear);
+    CHECK(b.colors[0].clearR == 0.25f);
+    CHECK(b.colors[0].clearB == 0.75f);
+    CHECK(b.depthStencil.view == kInvalidHandle);
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::setPipeline);
+    CHECK(r.read<SetPipelineCmd>().pipeline == 7u);
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::setVertexBuffer);
+    auto vb = r.read<SetVertexBufferCmd>();
+    CHECK(vb.slot == 0u);
+    CHECK(vb.buffer == 3u);
+    CHECK(vb.offset == 16u);
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::draw);
+    auto d = r.read<DrawCmd>();
+    CHECK(d.vertexCount == 6u);
+    CHECK(d.instanceCount == 2u);
+    CHECK(d.firstVertex == 1u);
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::finish);
+
+    REQUIRE_FALSE(r.next(t));
+}
+
+TEST_CASE("ore command buffer reset keeps the buffer reusable", "[ore][cmd]")
+{
+    OreCommandBuffer buf;
+    buf.append(CommandType::draw, DrawCmd{1, 1, 0, 0});
+    CHECK_FALSE(buf.empty());
+
+    buf.reset();
+    CHECK(buf.empty());
+    CHECK(buf.keepAlive().empty());
+
+    buf.appendOpcode(CommandType::finish);
+    CHECK_FALSE(buf.empty());
+}
+
+TEST_CASE("ore command buffer capture maps nullptr to kInvalidHandle",
+          "[ore][cmd]")
+{
+    OreCommandBuffer buf;
+    CHECK(buf.capture(nullptr) == kInvalidHandle);
+    CHECK(buf.keepAlive().empty());
+}
+
+TEST_CASE("a truncated trailing opcode latches overrun", "[ore][cmd]")
+{
+    // Ore opcodes are four bytes; three leftover bytes are a truncated
+    // stream, not a clean end.
+    std::vector<uint8_t> bytes = {1, 0, 0};
+    rive::cmd::CommandReader<uint32_t> truncated(
+        rive::Span<const uint8_t>(bytes.data(), bytes.size()),
+        rive::Span<const uint8_t>());
+    uint32_t op;
+    CHECK_FALSE(truncated.next(op));
+    CHECK(truncated.overrun());
+
+    rive::cmd::CommandReader<uint32_t> clean{rive::Span<const uint8_t>(),
+                                             rive::Span<const uint8_t>()};
+    CHECK_FALSE(clean.next(op));
+    CHECK_FALSE(clean.overrun());
+}
diff --git a/tests/unit_tests/renderer/ore_command_silver_test.cpp b/tests/unit_tests/renderer/ore_command_silver_test.cpp
new file mode 100644
index 0000000..c8f1e56
--- /dev/null
+++ b/tests/unit_tests/renderer/ore_command_silver_test.cpp
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Silver is the portable field wise form with a GPU free comparator, the
+// cross arch regression form that the host endian serialize is not.
+
+#include "rive/renderer/ore/cmd/ore_command_silver.hpp"
+
+#include <catch.hpp>
+#include <vector>
+
+using namespace rive::ore;
+using namespace rive::ore::cmd;
+
+// Covers every command type, including blob arena dynamic offsets and a
+// negative baseVertex.
+static void recordRepresentative(OreCommandBuffer& buf)
+{
+    BeginRenderPassCmd begin{};
+    begin.colorCount = 2;
+    begin.colors[0] = {0,
+                       kInvalidHandle,
+                       LoadOp::clear,
+                       StoreOp::store,
+                       0.1f,
+                       0.2f,
+                       0.3f,
+                       1.0f};
+    begin.colors[1] =
+        {1, 2, LoadOp::load, StoreOp::discard, 0.0f, 0.0f, 0.0f, 0.0f};
+    begin.depthStencil = {3,
+                          LoadOp::clear,
+                          StoreOp::store,
+                          1.0f,
+                          LoadOp::clear,
+                          StoreOp::store,
+                          0};
+    buf.append(CommandType::beginRenderPass, begin);
+
+    buf.append(CommandType::setPipeline, SetPipelineCmd{7});
+    buf.append(CommandType::setVertexBuffer, SetVertexBufferCmd{0, 4, 16});
+    buf.append(CommandType::setIndexBuffer,
+               SetIndexBufferCmd{5, IndexFormat::uint16, 0});
+
+    const uint32_t dynOffsets[2] = {64, 128};
+    uint64_t dynStart = buf.appendBlob(dynOffsets, sizeof(dynOffsets));
+    buf.append(CommandType::setBindGroup, SetBindGroupCmd{1, 6, dynStart, 2});
+
+    buf.append(CommandType::setViewport,
+               SetViewportCmd{0.f, 0.f, 256.f, 128.f, 0.f, 1.f});
+    buf.append(CommandType::setScissorRect, SetScissorRectCmd{0, 0, 256, 128});
+    buf.append(CommandType::setStencilReference, SetStencilReferenceCmd{0x80});
+    buf.append(CommandType::setBlendColor,
+               SetBlendColorCmd{1.f, 0.5f, 0.f, 1.f});
+    buf.append(CommandType::draw, DrawCmd{6, 2, 1, 0});
+    buf.append(CommandType::drawIndexed, DrawIndexedCmd{12, 1, 0, -3, 0});
+    buf.appendOpcode(CommandType::finish);
+}
+
+TEST_CASE("ore silver round-trips and self-compares equal", "[ore][cmd]")
+{
+    OreCommandBuffer buf;
+    recordRepresentative(buf);
+
+    std::vector<uint8_t> silver;
+    serializeSilver(buf, silver);
+    REQUIRE(silver.size() > sizeof(kSilverMagic));
+
+    // Identical recordings must serialize byte identical, so no host padding
+    // can leak in.
+    OreCommandBuffer buf2;
+    recordRepresentative(buf2);
+    std::vector<uint8_t> silver2;
+    serializeSilver(buf2, silver2);
+    CHECK(silver == silver2);
+
+    CHECK(silverMatch(silver, silver2));
+}
+
+TEST_CASE("ore silver detects a diverging field", "[ore][cmd]")
+{
+    OreCommandBuffer expected;
+    recordRepresentative(expected);
+    std::vector<uint8_t> expectedSilver;
+    serializeSilver(expected, expectedSilver);
+
+    OreCommandBuffer actual;
+    BeginRenderPassCmd begin{};
+    begin.colorCount = 1;
+    begin.colors[0] =
+        {0, kInvalidHandle, LoadOp::clear, StoreOp::store, 0.f, 0.f, 0.f, 1.f};
+    begin.depthStencil.view = kInvalidHandle;
+    actual.append(CommandType::beginRenderPass, begin);
+    actual.append(CommandType::draw, DrawCmd{99, 1, 0, 0});
+    std::vector<uint8_t> actualSilver;
+    serializeSilver(actual, actualSilver);
+
+    CHECK_FALSE(silverMatch(expectedSilver, actualSilver));
+}
+
+TEST_CASE("ore silver tolerates sub-epsilon float drift", "[ore][cmd]")
+{
+    OreCommandBuffer a;
+    a.append(CommandType::setBlendColor, SetBlendColorCmd{0.5f, 0.f, 0.f, 1.f});
+    std::vector<uint8_t> silverA;
+    serializeSilver(a, silverA);
+
+    OreCommandBuffer b;
+    b.append(CommandType::setBlendColor,
+             SetBlendColorCmd{0.5f + kSilverEpsilon * 0.5f, 0.f, 0.f, 1.f});
+    std::vector<uint8_t> silverB;
+    serializeSilver(b, silverB);
+
+    CHECK(silverMatch(silverA, silverB));
+}
diff --git a/tests/unit_tests/renderer/ore_deferred_alias_test.cpp b/tests/unit_tests/renderer/ore_deferred_alias_test.cpp
new file mode 100644
index 0000000..4cc9710
--- /dev/null
+++ b/tests/unit_tests/renderer/ore_deferred_alias_test.cpp
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Two lifetime guarantees on the session's resource maps: a recycled address
+// resolves through whatever object holds it now, and a session's teardown may
+// run off the recording thread.
+
+#include "rive/renderer/ore/cmd/ore_deferred_context.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_resource.hpp"
+
+#include <catch.hpp>
+
+#include <memory>
+#include <thread>
+#include <unordered_set>
+#include <vector>
+
+using namespace rive;
+using namespace rive::ore;
+using namespace rive::ore::cmd;
+
+// Skip under AddressSanitizer: its quarantine holds freed blocks back, so the
+// allocator never hands a dead resource's address to the next one and the
+// aliasing this test exists to pin cannot be set up. The test REQUIREs the
+// recycle it depends on, so it fails loudly rather than passing vacuously
+// wherever the premise does not hold.
+#ifndef __has_feature
+#define __has_feature(x) 0
+#endif
+#if !defined(__SANITIZE_ADDRESS__) && !__has_feature(address_sanitizer)
+TEST_CASE("a recycled address resolves through the object holding it now",
+          "[ore_deferred_alias]")
+{
+    // A DeferredResource's destructor only queues its destroy, so the
+    // allocator can hand a dead resource's address to a new one long before
+    // anything that recorded that address has been cleaned up. One session's
+    // dead resource leaves an address behind and the object that lands there
+    // next belongs to a different session, so nothing the creator does can
+    // reach the first session's memory of it. Only the object itself can
+    // answer for the address, which is why the lookup asks the object.
+    DeferredOreContext a(nullptr);
+    ShaderModuleDesc smDesc{};
+
+    // Dropping these queues destroys that nothing drains, so their ids and
+    // their addresses are both loose while a still recorded them.
+    constexpr int kCount = 32;
+    std::unordered_set<const rive::gpu::GPUResource*> dead;
+    {
+        std::vector<rcp<ShaderModule>> sessionMods;
+        for (int i = 0; i < kCount; ++i)
+        {
+            sessionMods.push_back(a.makeShaderModule(smDesc));
+            dead.insert(sessionMods.back().get());
+        }
+    }
+
+    // b creates the modules that reclaim those addresses.
+    DeferredOreContext b(nullptr);
+    ShaderModule* recycled = nullptr;
+    std::vector<rcp<ShaderModule>> bMods;
+    for (int i = 0; i < kCount && recycled == nullptr; ++i)
+    {
+        bMods.push_back(b.makeShaderModule(smDesc));
+        ShaderModule* mod = bMods.back().get();
+        recycled = dead.count(mod) != 0 ? mod : nullptr;
+    }
+    REQUIRE(recycled != nullptr);
+
+    // b records it, so b names it by the id it created it under.
+    ResourceHandle own =
+        static_cast<DeferredShaderModule*>(recycled)->clientHandle();
+    CHECK((own & kRealResourceFlag) == 0);
+    CHECK(b.handleFor(recycled) == own);
+
+    // a must not reuse that id: it indexes the table a's own stream feeds,
+    // where the same number names something else. A deferred object that
+    // records into a foreign stream takes the real resource path instead.
+    ResourceHandle foreign = a.handleFor(recycled);
+    CHECK(foreign != own);
+    CHECK((foreign & kRealResourceFlag) != 0);
+
+    // And a pipeline a builds over it carries that same real reference, so
+    // replay resolves it from the retained side table rather than binding
+    // whatever a holds at b's id.
+    PipelineDesc pDesc{};
+    pDesc.vertexModule = recycled;
+    REQUIRE(a.makePipeline(pDesc) != nullptr);
+    CHECK(a.handleFor(recycled) == foreign);
+}
+#endif
+
+TEST_CASE("session teardown off the recording thread stays quiet",
+          "[ore_deferred_alias]")
+{
+    // The recording thread assertion must not fire on the paths the deferred
+    // design puts off thread on purpose. Dart finalizers release resources on
+    // GC threads, and on threaded wasm riveDeleteDeferredSession posts the
+    // delete to the replay worker, so a session's last destroy drain runs
+    // there rather than on the thread that recorded it.
+    auto d = std::make_unique<DeferredOreContext>(nullptr);
+    BufferDesc bufDesc{};
+    bufDesc.size = 16;
+    auto live = d->makeBuffer(bufDesc);
+
+    // A finalizer thread dropping the last reference while the session is
+    // still recording: the destroy queues under the destroy mutex.
+    {
+        auto doomed = d->makeBuffer(bufDesc);
+        std::thread finalizer([&] { doomed = nullptr; });
+        finalizer.join();
+    }
+
+    // Teardown on a third thread, which drains that queue and records the
+    // destroys into a stream it never appended to before.
+    live = nullptr;
+    std::thread worker([&] { d = nullptr; });
+    worker.join();
+    CHECK(d == nullptr);
+}
diff --git a/tests/unit_tests/renderer/ore_deferred_device_state_test.cpp b/tests/unit_tests/renderer/ore_deferred_device_state_test.cpp
new file mode 100644
index 0000000..4c4d614
--- /dev/null
+++ b/tests/unit_tests/renderer/ore_deferred_device_state_test.cpp
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// What a recording context answers about the device its stream will run on.
+// A recorded capability branch is not a readout, it is a prediction that gets
+// written into a stream and replayed on real hardware, so an answer that is
+// merely plausible is worse than no answer at all: replay executes the wrong
+// branch flawlessly and nothing downstream can tell.
+
+#include "rive/renderer/ore/cmd/ore_deferred_context.hpp"
+#include "rive/renderer/ore/ore_context.hpp"
+
+#include <catch.hpp>
+
+using namespace rive;
+using namespace rive::ore;
+using namespace rive::ore::cmd;
+
+namespace
+{
+// GPU free stand-in for a real backend context: the only thing under test is
+// what it advertises, so the factories are unreachable.
+class FakeDeviceContext : public Context
+{
+public:
+    FakeDeviceContext() : Context(nullptr) {}
+
+    Features& editableFeatures() { return m_features; }
+
+    rcp<Buffer> makeBuffer(const BufferDesc&) override { return nullptr; }
+    rcp<Texture> makeTexture(const TextureDesc&) override { return nullptr; }
+    rcp<TextureView> makeTextureView(const TextureViewDesc&) override
+    {
+        return nullptr;
+    }
+    rcp<Sampler> makeSampler(const SamplerDesc&) override { return nullptr; }
+    rcp<ShaderModule> makeShaderModule(const ShaderModuleDesc&) override
+    {
+        return nullptr;
+    }
+    rcp<BindGroupLayout> makeBindGroupLayout(
+        const BindGroupLayoutDesc&) override
+    {
+        return nullptr;
+    }
+    rcp<Pipeline> makePipeline(const PipelineDesc&, std::string*) override
+    {
+        return nullptr;
+    }
+    rcp<BindGroup> makeBindGroup(const BindGroupDesc&) override
+    {
+        return nullptr;
+    }
+    std::unique_ptr<RenderPass> beginRenderPass(const RenderPassDesc&,
+                                                std::string*) override
+    {
+        return nullptr;
+    }
+    void beginFrame(const FrameDescriptor&) override {}
+    void endFrame() override {}
+    void waitForGPU() override {}
+    rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas*) override
+    {
+        return nullptr;
+    }
+    rcp<TextureView> wrapRiveTexture(gpu::Texture*, uint32_t, uint32_t) override
+    {
+        return nullptr;
+    }
+    ShaderTarget shaderTarget() const override { return ShaderTarget::glsl; }
+};
+} // namespace
+
+TEST_CASE("a recording context reports the replay device's capabilities",
+          "[ore][cmd][deferred]")
+{
+    FakeDeviceContext device;
+    Features& real = device.editableFeatures();
+    // A device more capable than Features' initializers in both directions:
+    // a flag they deny and a limit they understate.
+    real.colorBufferHalfFloat = true;
+    real.maxSamples = 8;
+    real.maxTextureSize2D = 16384;
+
+    SECTION("bound at construction, as every native host binds")
+    {
+        DeferredOreContext recorder(&device);
+        CHECK(recorder.featuresKnown());
+        CHECK(recorder.features().colorBufferHalfFloat);
+        CHECK(recorder.features().maxSamples == 8u);
+        CHECK(recorder.features().maxTextureSize2D == 16384u);
+    }
+
+    SECTION("bound late, as web binds on attach")
+    {
+        DeferredOreContext recorder(nullptr);
+        recorder.bindReal(&device);
+        CHECK(recorder.featuresKnown());
+        CHECK(recorder.features().colorBufferHalfFloat);
+        CHECK(recorder.features().maxSamples == 8u);
+    }
+
+    SECTION("unbound, nothing has been measured and it says so")
+    {
+        // The values are still Features' initializers, which is exactly why
+        // featuresKnown has to exist: they read as a real, poor device, so no
+        // caller can distinguish a guess from a measurement by inspecting them.
+        DeferredOreContext recorder(nullptr);
+        CHECK_FALSE(recorder.featuresKnown());
+        CHECK_FALSE(recorder.features().colorBufferHalfFloat);
+    }
+}
+
+TEST_CASE("a real context always knows its own capabilities",
+          "[ore][cmd][deferred]")
+{
+    FakeDeviceContext device;
+    CHECK(device.featuresKnown());
+}
diff --git a/tests/unit_tests/renderer/ore_deferred_reuse_test.cpp b/tests/unit_tests/renderer/ore_deferred_reuse_test.cpp
new file mode 100644
index 0000000..ddd4f36
--- /dev/null
+++ b/tests/unit_tests/renderer/ore_deferred_reuse_test.cpp
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Producer side id reuse: the generational free list and a destroy then
+// recreate lifecycle in one ordered stream, which the GMs never exercise.
+// GPU free, asserts the recorded bytes.
+
+#include "rive/renderer/cmd/id_allocator.hpp"
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_commands.hpp"
+#include "rive/renderer/ore/cmd/ore_make_recording.hpp"
+
+#include <catch.hpp>
+#include <cstring>
+
+using namespace rive::ore;
+using namespace rive::ore::cmd;
+using rive::IdAllocator;
+using rive::Span;
+
+TEST_CASE("IdAllocator recycles ids with a bumped generation",
+          "[ore][cmd][reuse]")
+{
+    IdAllocator<uint32_t> ids;
+
+    SECTION("a freed id returns at generation+1")
+    {
+        auto a = ids.alloc();
+        auto b = ids.alloc();
+        REQUIRE(a.id == 0u);
+        REQUIRE(b.id == 1u);
+
+        ids.release(a.id, a.generation);
+        auto c = ids.alloc();
+        CHECK(c.id == 0u);
+        CHECK(c.generation == 1u);
+
+        // Free list is empty again so the alloc is fresh.
+        auto d = ids.alloc();
+        CHECK(d.id == 2u);
+        CHECK(d.generation == 0u);
+
+        // Generation keeps climbing on repeated recycling.
+        ids.release(c.id, c.generation);
+        auto e = ids.alloc();
+        CHECK(e.id == 0u);
+        CHECK(e.generation == 2u);
+    }
+
+    SECTION("an id whose generation would overflow is retired, never recycled")
+    {
+        auto a = ids.alloc();
+        // Bumping the max generation would wrap, so the id is dropped.
+        ids.release(a.id, 0xffffffffu);
+        auto b = ids.alloc();
+        CHECK(b.id == 1u);
+        CHECK(b.generation == 0u);
+    }
+}
+
+TEST_CASE("ordered stream records a create/write/destroy/recreate lifecycle",
+          "[ore][cmd][reuse]")
+{
+    // Mirrors DeferredOreContext, one allocator and one ordered stream.
+    IdAllocator<rive::ore::cmd::ResourceHandle> ids;
+    OreCommandBuffer cb;
+
+    auto a = ids.alloc();
+    BufferDesc bd{};
+    bd.size = 16;
+    bd.usage = BufferUsage::vertex;
+    recordMakeBuffer(cb, a.id, a.generation, bd);
+
+    const uint32_t data[4] = {10, 20, 30, 40};
+    recordBufferUpdate(cb, a.id, data, sizeof(data), 0);
+
+    // The destroy records into the same stream after the write.
+    recordDestroyResource(cb, a.id, a.generation);
+    ids.release(a.id, a.generation);
+
+    auto b = ids.alloc();
+    REQUIRE(b.id == a.id);
+    REQUIRE(b.generation == 1u);
+    BufferDesc bd2{};
+    bd2.size = 32;
+    bd2.usage = BufferUsage::index;
+    recordMakeBuffer(cb, b.id, b.generation, bd2);
+
+    OreCommandReader r(cb.commandBytes(), cb.blobBytes());
+    CommandType type;
+
+    REQUIRE(r.next(type));
+    REQUIRE(type == CommandType::makeBuffer);
+    auto m0 = r.read<MakeResourcePOD>();
+    auto d0 = r.read<BufferDescPOD>();
+    CHECK(m0.id == a.id);
+    CHECK(m0.generation == 0u);
+    CHECK(d0.size == 16u);
+    CHECK(d0.usage == BufferUsage::vertex);
+
+    REQUIRE(r.next(type));
+    REQUIRE(type == CommandType::bufferUpdate);
+    auto up = r.read<BufferUpdatePOD>();
+    CHECK(up.handle == a.id);
+    CHECK(up.offset == 0u);
+    Span<const uint8_t> bytes = r.blobAt(up.bytes.offset, up.bytes.size);
+    REQUIRE(bytes.size() == sizeof(data));
+    CHECK(std::memcmp(bytes.data(), data, sizeof(data)) == 0);
+
+    REQUIRE(r.next(type));
+    REQUIRE(type == CommandType::destroyResource);
+    auto ds = r.read<DestroyResourcePOD>();
+    CHECK(ds.handle == a.id);
+    CHECK(ds.generation == 0u);
+
+    REQUIRE(r.next(type));
+    REQUIRE(type == CommandType::makeBuffer);
+    auto m1 = r.read<MakeResourcePOD>();
+    auto d1 = r.read<BufferDescPOD>();
+    CHECK(m1.id == a.id);
+    CHECK(m1.generation == 1u);
+    CHECK(d1.size == 32u);
+    CHECK(d1.usage == BufferUsage::index);
+
+    CHECK_FALSE(r.next(type));
+}
diff --git a/tests/unit_tests/renderer/ore_make_recording_test.cpp b/tests/unit_tests/renderer/ore_make_recording_test.cpp
new file mode 100644
index 0000000..6163c23
--- /dev/null
+++ b/tests/unit_tests/renderer/ore_make_recording_test.cpp
@@ -0,0 +1,379 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Confirms every make descriptor field, label string, and data blob round
+// trips through the ordered ore stream with the caller's id and generation.
+// GPU free, no real resources.
+
+#include "rive/renderer/ore/cmd/ore_command_buffer.hpp"
+#include "rive/renderer/ore/cmd/ore_make_recording.hpp"
+
+#include <catch.hpp>
+#include <cstring>
+
+using namespace rive::ore;
+using namespace rive::ore::cmd;
+using rive::Span;
+
+namespace
+{
+Span<const uint8_t> blobOf(const OreCommandReader& r, BlobRef ref)
+{
+    return ref.absent() ? Span<const uint8_t>(nullptr, 0)
+                        : r.blobAt(ref.offset, ref.size);
+}
+const char* cstrOf(const OreCommandReader& r, BlobRef ref)
+{
+    return reinterpret_cast<const char*>(blobOf(r, ref).data());
+}
+} // namespace
+
+TEST_CASE("make stream records make* with the caller's ids", "[ore][cmd]")
+{
+    OreCommandBuffer cb;
+
+    const uint32_t verts[4] = {1, 2, 3, 4};
+    BufferDesc bd{};
+    bd.usage = BufferUsage::vertex;
+    bd.size = sizeof(verts);
+    bd.data = verts;
+    bd.immutable = true;
+    bd.label = "vb";
+    recordMakeBuffer(cb, 0, 1, bd);
+
+    TextureDesc td{};
+    td.width = 256;
+    td.height = 128;
+    td.depthOrArrayLayers = 1;
+    td.format = TextureFormat::rgba8unorm;
+    td.type = TextureType::texture2D;
+    td.renderTarget = true;
+    td.numMipmaps = 1;
+    td.sampleCount = 4;
+    td.label = "rt";
+    recordMakeTexture(cb, 1, 1, td);
+
+    SamplerDesc sd{};
+    sd.minFilter = Filter::linear;
+    sd.magFilter = Filter::nearest;
+    sd.mipmapFilter = Filter::linear;
+    sd.wrapU = WrapMode::repeat;
+    sd.wrapV = WrapMode::clampToEdge;
+    sd.wrapW = WrapMode::mirrorRepeat;
+    sd.compare = CompareFunction::less;
+    sd.minLod = 0.5f;
+    sd.maxLod = 7.0f;
+    sd.maxAnisotropy = 8;
+    sd.label = nullptr; // null label must round trip as absent
+    recordMakeSampler(cb, 2, 3, sd);
+
+    OreCommandReader r(cb.commandBytes(), cb.blobBytes());
+    CommandType t;
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::makeBuffer);
+    auto bh = r.read<MakeResourcePOD>();
+    CHECK(bh.id == 0u);
+    CHECK(bh.generation == 1u);
+    auto b = r.read<BufferDescPOD>();
+    CHECK(b.usage == BufferUsage::vertex);
+    CHECK(b.size == sizeof(verts));
+    CHECK(b.immutable);
+    auto bData = blobOf(r, b.data);
+    REQUIRE(bData.size() == sizeof(verts));
+    CHECK(std::memcmp(bData.data(), verts, sizeof(verts)) == 0);
+    auto bLabel = blobOf(r, b.label);
+    REQUIRE(bLabel.size() == 3u); // "vb\0"
+    CHECK(std::strcmp(cstrOf(r, b.label), "vb") == 0);
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::makeTexture);
+    auto th = r.read<MakeResourcePOD>();
+    CHECK(th.id == 1u);
+    auto tx = r.read<TextureDescPOD>();
+    CHECK(tx.width == 256u);
+    CHECK(tx.height == 128u);
+    CHECK(tx.format == TextureFormat::rgba8unorm);
+    CHECK(tx.type == TextureType::texture2D);
+    CHECK(tx.renderTarget);
+    CHECK(tx.sampleCount == 4u);
+    CHECK(std::strcmp(cstrOf(r, tx.label), "rt") == 0);
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::makeSampler);
+    auto sh = r.read<MakeResourcePOD>();
+    CHECK(sh.id == 2u);
+    CHECK(sh.generation == 3u);
+    auto s = r.read<SamplerDescPOD>();
+    CHECK(s.minFilter == Filter::linear);
+    CHECK(s.magFilter == Filter::nearest);
+    CHECK(s.wrapU == WrapMode::repeat);
+    CHECK(s.wrapW == WrapMode::mirrorRepeat);
+    CHECK(s.compare == CompareFunction::less);
+    CHECK(s.minLod == 0.5f);
+    CHECK(s.maxLod == 7.0f);
+    CHECK(s.maxAnisotropy == 8u);
+    CHECK(s.label.absent());
+
+    REQUIRE_FALSE(r.next(t));
+}
+
+TEST_CASE("make stream: a buffer with no initial data is absent, not empty",
+          "[ore][cmd]")
+{
+    OreCommandBuffer cb;
+    BufferDesc bd{};
+    bd.usage = BufferUsage::uniform;
+    bd.size = 64;
+    bd.data = nullptr;
+    recordMakeBuffer(cb, 0, 0, bd);
+
+    OreCommandReader r(cb.commandBytes(), cb.blobBytes());
+    CommandType t;
+    REQUIRE(r.next(t));
+    r.read<MakeResourcePOD>();
+    auto b = r.read<BufferDescPOD>();
+    CHECK(b.size == 64u);
+    CHECK(b.data.absent());
+    CHECK(blobOf(r, b.data).size() == 0u);
+}
+
+TEST_CASE("make stream records shader module, layout, view", "[ore][cmd]")
+{
+    OreCommandBuffer cb;
+
+    const uint8_t code[8] = {0xDE, 0xAD, 0xBE, 0xEF, 1, 2, 3, 4};
+    const uint8_t bmap[3] = {9, 8, 7};
+    ShaderModuleDesc sm{};
+    sm.code = code;
+    sm.codeSize = sizeof(code);
+    sm.language = ShaderLanguage::wgsl;
+    sm.stage = ShaderStage::vertex;
+    sm.bindingMapBytes = bmap;
+    sm.bindingMapSize = sizeof(bmap);
+    sm.shaderAssetId = 42;
+    recordMakeShaderModule(cb, 0, 0, sm);
+
+    BindGroupLayoutEntry entries[2]{};
+    entries[0].binding = 0;
+    entries[0].kind = BindingKind::uniformBuffer;
+    entries[0].hasDynamicOffset = true;
+    entries[1].binding = 1;
+    entries[1].kind = BindingKind::sampledTexture;
+    entries[1].nativeSlotFS = 5;
+    BindGroupLayoutDesc bgl{};
+    bgl.groupIndex = 2;
+    bgl.entries = entries;
+    bgl.entryCount = 2;
+    recordMakeBindGroupLayout(cb, 1, 0, bgl);
+
+    // The view references the texture by handle.
+    TextureDesc td{};
+    td.width = td.height = 64;
+    recordMakeTexture(cb, 2, 0, td);
+    TextureViewDesc tv{};
+    tv.dimension = TextureViewDimension::texture2D;
+    tv.baseMipLevel = 1;
+    tv.mipCount = 2;
+    recordMakeTextureView(cb, 3, 0, tv, 2);
+
+    OreCommandReader r(cb.commandBytes(), cb.blobBytes());
+    CommandType t;
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::makeShaderModule);
+    r.read<MakeResourcePOD>();
+    auto s = r.read<ShaderModuleDescPOD>();
+    CHECK(s.language == ShaderLanguage::wgsl);
+    CHECK(s.stage == ShaderStage::vertex);
+    CHECK(s.shaderAssetId == 42u);
+    auto codeBlob = blobOf(r, s.code);
+    REQUIRE(codeBlob.size() == sizeof(code));
+    CHECK(std::memcmp(codeBlob.data(), code, sizeof(code)) == 0);
+    CHECK(blobOf(r, s.bindingMapBytes).size() == sizeof(bmap));
+    CHECK(s.hlslSource.absent());
+    CHECK(s.label.absent());
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::makeBindGroupLayout);
+    r.read<MakeResourcePOD>();
+    auto l = r.read<BindGroupLayoutDescPOD>();
+    CHECK(l.groupIndex == 2u);
+    CHECK(l.entryCount == 2u);
+    auto entriesBlob = blobOf(r, l.entries);
+    REQUIRE(entriesBlob.size() == 2 * sizeof(BindGroupLayoutEntry));
+    const auto* outEntries =
+        reinterpret_cast<const BindGroupLayoutEntry*>(entriesBlob.data());
+    CHECK(outEntries[0].binding == 0u);
+    CHECK(outEntries[0].hasDynamicOffset);
+    CHECK(outEntries[1].binding == 1u);
+    CHECK(outEntries[1].kind == BindingKind::sampledTexture);
+    CHECK(outEntries[1].nativeSlotFS == 5u);
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::makeTexture);
+    r.read<MakeResourcePOD>();
+    r.read<TextureDescPOD>();
+
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::makeTextureView);
+    auto vh = r.read<MakeResourcePOD>();
+    CHECK(vh.id == 3u);
+    auto v = r.read<TextureViewDescPOD>();
+    CHECK(v.texture == 2u);
+    CHECK(v.baseMipLevel == 1u);
+    CHECK(v.mipCount == 2u);
+}
+
+TEST_CASE("make stream records a pipeline with vertex layouts + refs",
+          "[ore][cmd]")
+{
+    OreCommandBuffer cb;
+
+    // Stand ins for handles recorded earlier.
+    const ResourceHandle vsModule = 10, fsModule = 11, layout0 = 12,
+                         layout1 = 13;
+
+    VertexAttribute attrs[2]{};
+    attrs[0] = {VertexFormat::float2, 0, 0};
+    attrs[1] = {VertexFormat::float4, 8, 1};
+    VertexBufferLayout vbl{};
+    vbl.stride = 24;
+    vbl.stepMode = VertexStepMode::vertex;
+    vbl.attributes = attrs;
+    vbl.attributeCount = 2;
+
+    PipelineDesc pd{};
+    pd.vertexEntryPoint = "vs_main";
+    pd.fragmentEntryPoint = "fs_main";
+    pd.vertexBuffers = &vbl;
+    pd.vertexBufferCount = 1;
+    pd.topology = PrimitiveTopology::triangleList;
+    pd.colorTargets[0].format = TextureFormat::rgba8unorm;
+    pd.colorTargets[0].blendEnabled = true;
+    pd.colorCount = 1;
+    pd.sampleCount = 4;
+    pd.label = "pipe";
+    ResourceHandle bglHandles[2] = {layout0, layout1};
+    recordMakePipeline(cb,
+                       0,
+                       0,
+                       pd,
+                       vsModule,
+                       fsModule,
+                       Span<const ResourceHandle>(bglHandles, 2));
+
+    OreCommandReader r(cb.commandBytes(), cb.blobBytes());
+    CommandType t;
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::makePipeline);
+    r.read<MakeResourcePOD>();
+    auto p = r.read<PipelineDescPOD>();
+    CHECK(p.vertexModule == vsModule);
+    CHECK(p.fragmentModule == fsModule);
+    CHECK(p.colorCount == 1u);
+    CHECK(p.colorTargets[0].format == TextureFormat::rgba8unorm);
+    CHECK(p.colorTargets[0].blendEnabled);
+    CHECK(p.sampleCount == 4u);
+    CHECK(std::strcmp(cstrOf(r, p.vertexEntryPoint), "vs_main") == 0);
+
+    auto bglBlob = blobOf(r, p.bindGroupLayouts);
+    REQUIRE(p.bindGroupLayoutCount == 2u);
+    REQUIRE(bglBlob.size() == 2 * sizeof(ResourceHandle));
+    const auto* bgl = reinterpret_cast<const ResourceHandle*>(bglBlob.data());
+    CHECK(bgl[0] == layout0);
+    CHECK(bgl[1] == layout1);
+
+    REQUIRE(p.vertexBufferCount == 1u);
+    auto vbBlob = blobOf(r, p.vertexBuffers);
+    REQUIRE(vbBlob.size() == sizeof(VertexBufferLayoutPOD));
+    const auto* vb =
+        reinterpret_cast<const VertexBufferLayoutPOD*>(vbBlob.data());
+    CHECK(vb[0].stride == 24u);
+    CHECK(vb[0].attributeCount == 2u);
+    auto attrBlob = blobOf(r, vb[0].attributes);
+    REQUIRE(attrBlob.size() == 2 * sizeof(VertexAttribute));
+    const auto* outAttrs =
+        reinterpret_cast<const VertexAttribute*>(attrBlob.data());
+    CHECK(outAttrs[0].format == VertexFormat::float2);
+    CHECK(outAttrs[1].format == VertexFormat::float4);
+    CHECK(outAttrs[1].offset == 8u);
+    CHECK(outAttrs[1].shaderSlot == 1u);
+}
+
+TEST_CASE("make stream records a bind group with entry refs", "[ore][cmd]")
+{
+    OreCommandBuffer cb;
+    const ResourceHandle layout = 5, buf0 = 6, view0 = 7, samp0 = 8;
+
+    BindGroupDesc::UBOEntry ubo{};
+    ubo.slot = 0;
+    ubo.offset = 16;
+    ubo.size = 256;
+    BindGroupDesc::TexEntry tex{};
+    tex.slot = 1;
+    BindGroupDesc::SampEntry samp{};
+    samp.slot = 2;
+
+    BindGroupDesc bg{};
+    bg.layout = nullptr; // unused, the ref is passed explicitly
+    bg.ubos = &ubo;
+    bg.uboCount = 1;
+    bg.textures = &tex;
+    bg.textureCount = 1;
+    bg.samplers = &samp;
+    bg.samplerCount = 1;
+    bg.label = "bg";
+
+    ResourceHandle uboH[1] = {buf0}, texH[1] = {view0}, sampH[1] = {samp0};
+    recordMakeBindGroup(cb,
+                        0,
+                        0,
+                        bg,
+                        layout,
+                        Span<const ResourceHandle>(uboH, 1),
+                        Span<const ResourceHandle>(texH, 1),
+                        Span<const ResourceHandle>(sampH, 1));
+
+    OreCommandReader r(cb.commandBytes(), cb.blobBytes());
+    CommandType t;
+    REQUIRE(r.next(t));
+    REQUIRE(t == CommandType::makeBindGroup);
+    r.read<MakeResourcePOD>();
+    auto b = r.read<BindGroupDescPOD>();
+    CHECK(b.layout == layout);
+    REQUIRE(b.uboCount == 1u);
+    REQUIRE(b.textureCount == 1u);
+    REQUIRE(b.samplerCount == 1u);
+
+    const auto* ubos =
+        reinterpret_cast<const UBOEntryPOD*>(blobOf(r, b.ubos).data());
+    CHECK(ubos[0].slot == 0u);
+    CHECK(ubos[0].buffer == buf0);
+    CHECK(ubos[0].offset == 16u);
+    CHECK(ubos[0].size == 256u);
+    const auto* texs =
+        reinterpret_cast<const TexEntryPOD*>(blobOf(r, b.textures).data());
+    CHECK(texs[0].slot == 1u);
+    CHECK(texs[0].view == view0);
+    const auto* samps =
+        reinterpret_cast<const SampEntryPOD*>(blobOf(r, b.samplers).data());
+    CHECK(samps[0].slot == 2u);
+    CHECK(samps[0].sampler == samp0);
+}
+
+TEST_CASE("make stream reset reuses the buffer", "[ore][cmd]")
+{
+    OreCommandBuffer cb;
+    TextureDesc td{};
+    td.width = td.height = 16;
+    recordMakeTexture(cb, 0, 0, td);
+    recordMakeTexture(cb, 1, 0, td);
+    CHECK_FALSE(cb.empty());
+
+    cb.reset();
+    CHECK(cb.empty());
+    recordMakeTexture(cb, 0, 1, td);
+    CHECK_FALSE(cb.empty());
+}
diff --git a/tests/unit_tests/renderer/ore_render_pass_recording_test.cpp b/tests/unit_tests/renderer/ore_render_pass_recording_test.cpp
new file mode 100644
index 0000000..687ccbe
--- /dev/null
+++ b/tests/unit_tests/renderer/ore_render_pass_recording_test.cpp
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// A RenderPassRecording must emit exactly the stream a hand built
+// OreCommandBuffer would, compared by silver. Null resources capture as
+// kInvalidHandle so no GPU is required.
+
+#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp"
+#include "rive/renderer/ore/cmd/ore_command_silver.hpp"
+
+#include <catch.hpp>
+#include <vector>
+
+using namespace rive::ore;
+using namespace rive::ore::cmd;
+
+TEST_CASE("RenderPassRecording emits the expected command stream", "[ore][cmd]")
+{
+    RenderPassDesc desc;
+    desc.colorCount = 1;
+    desc.colorAttachments[0].view = nullptr;
+    desc.colorAttachments[0].resolveTarget = nullptr;
+    desc.colorAttachments[0].loadOp = LoadOp::clear;
+    desc.colorAttachments[0].storeOp = StoreOp::store;
+    desc.colorAttachments[0].clearColor = {0.25f, 0.5f, 0.75f, 1.0f};
+    desc.depthStencil.view = nullptr;
+
+    OreCommandBuffer recorded;
+    {
+        // A null context is safe, validation only dereferences it to report
+        // errors.
+        RenderPassRecording pass(nullptr, &recorded, desc);
+        pass.setPipeline(nullptr);
+        pass.setViewport(0.f, 0.f, 128.f, 64.f, 0.f, 1.f);
+        pass.setScissorRect(0, 0, 128, 64);
+        pass.draw(6, 1, 0, 0);
+        pass.finish();
+    }
+
+    // Hand built reference stream.
+    OreCommandBuffer expected;
+    BeginRenderPassCmd begin{};
+    begin.colorCount = 1;
+    begin.colors[0] = {kInvalidHandle,
+                       kInvalidHandle,
+                       LoadOp::clear,
+                       StoreOp::store,
+                       0.25f,
+                       0.5f,
+                       0.75f,
+                       1.0f};
+    begin.depthStencil.view = kInvalidHandle;
+    begin.depthStencil.depthLoadOp = LoadOp::clear;
+    begin.depthStencil.depthStoreOp = StoreOp::store;
+    begin.depthStencil.depthClearValue = 1.0f;
+    begin.depthStencil.stencilLoadOp = LoadOp::clear;
+    begin.depthStencil.stencilStoreOp = StoreOp::discard;
+    begin.depthStencil.stencilClearValue = 0;
+    expected.append(CommandType::beginRenderPass, begin);
+    // A null pipeline records an invalid handle.
+    expected.append(CommandType::setPipeline, SetPipelineCmd{kInvalidHandle});
+    expected.append(CommandType::setViewport,
+                    SetViewportCmd{0.f, 0.f, 128.f, 64.f, 0.f, 1.f});
+    expected.append(CommandType::setScissorRect,
+                    SetScissorRectCmd{0, 0, 128, 64});
+    expected.append(CommandType::draw, DrawCmd{6, 1, 0, 0});
+    expected.appendOpcode(CommandType::finish);
+
+    std::vector<uint8_t> recordedSilver, expectedSilver;
+    serializeSilver(recorded, recordedSilver);
+    serializeSilver(expected, expectedSilver);
+    CHECK(silverMatch(expectedSilver, recordedSilver));
+}
+
+TEST_CASE("RenderPassRecording finish is idempotent", "[ore][cmd]")
+{
+    RenderPassDesc desc;
+    desc.colorCount = 0;
+    desc.depthStencil.view = nullptr;
+
+    OreCommandBuffer recorded;
+    RenderPassRecording pass(nullptr, &recorded, desc);
+    pass.draw(3, 1, 0, 0);
+    pass.finish();
+    CHECK(pass.isFinished());
+    pass.finish();
+
+    OreCommandReader r(recorded.commandBytes(), recorded.blobBytes());
+    CommandType t;
+    int finishes = 0;
+    int total = 0;
+    while (r.next(t))
+    {
+        ++total;
+        if (t == CommandType::finish)
+        {
+            ++finishes;
+            continue;
+        }
+        // The reader requires consuming each payload.
+        switch (t)
+        {
+            case CommandType::beginRenderPass:
+                r.read<BeginRenderPassCmd>();
+                break;
+            case CommandType::draw:
+                r.read<DrawCmd>();
+                break;
+            default:
+                FAIL("unexpected command in stream");
+        }
+    }
+    CHECK(finishes == 1);
+    CHECK(total == 3);
+}
diff --git a/tests/unit_tests/renderer/pls_path_test.cpp b/tests/unit_tests/renderer/pls_path_test.cpp
index dc68279..657d9a8 100644
--- a/tests/unit_tests/renderer/pls_path_test.cpp
+++ b/tests/unit_tests/renderer/pls_path_test.cpp
@@ -75,4 +75,26 @@
               (math::PI * 1000 * 1000 - math::PI * 900 * 900) ==
           Approx(1).margin(1e-2f));
 }
+
+TEST_CASE("addRawPath invalidates derived state", "[RiveRenderPath]")
+{
+    RiveRenderPath path;
+
+    RawPath first;
+    first.addRect({0, 0, 10, 10}, PathDirection::clockwise);
+    path.addRawPath(first);
+
+    // Warm the caches so a missing invalidation shows up below.
+    CHECK(path.getBounds().right() == 10);
+    CHECK(path.getCoarseArea() == 100);
+    uint64_t firstMutationID = path.getRawPathMutationID();
+
+    RawPath second;
+    second.addRect({20, 20, 40, 40}, PathDirection::clockwise);
+    path.addRawPath(second);
+
+    CHECK(path.getBounds().right() == 40);
+    CHECK(path.getCoarseArea() == 100 + 400);
+    CHECK(path.getRawPathMutationID() != firstMutationID);
+}
 } // namespace rive::gpu
diff --git a/tests/unit_tests/runtime/command_queue_test.cpp b/tests/unit_tests/runtime/command_queue_test.cpp
index 16945dd..0f4eb74 100644
--- a/tests/unit_tests/runtime/command_queue_test.cpp
+++ b/tests/unit_tests/runtime/command_queue_test.cpp
@@ -5607,7 +5607,11 @@
 
 static void local_server_thread(CommandServer* server)
 {
+#ifndef NDEBUG
+    // Only exists to satisfy the server's debug-only thread asserts, and the
+    // override itself is compiled out with them.
     server->testing_overrideThreadID(std::this_thread::get_id());
+#endif
     server->serveUntilDisconnect();
 }
 
diff --git a/tests/unit_tests/runtime/instance_factory_test.cpp b/tests/unit_tests/runtime/instance_factory_test.cpp
new file mode 100644
index 0000000..1455eb4
--- /dev/null
+++ b/tests/unit_tests/runtime/instance_factory_test.cpp
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// An artboard instanced with an override factory routes all instance level
+// render resource creation through it, nested instances included; the file
+// level factory keeps only the shared decode products.
+
+#include <rive/artboard.hpp>
+#include <rive/factory.hpp>
+#include <rive/nested_artboard.hpp>
+#include <utils/no_op_renderer.hpp>
+#include "rive_file_reader.hpp"
+
+#include <catch.hpp>
+
+using namespace rive;
+
+namespace
+{
+// Counts creations, delegates through the base so no op objects come back.
+class CountingFactory : public Factory
+{
+public:
+    int paints = 0;
+    int paths = 0;
+    int buffers = 0;
+    int shaders = 0;
+
+    rcp<RenderBuffer> makeRenderBuffer(RenderBufferType type,
+                                       RenderBufferFlags flags,
+                                       size_t size) override
+    {
+        ++buffers;
+        return inner().makeRenderBuffer(type, flags, size);
+    }
+    rcp<RenderShader> makeLinearGradient(float sx,
+                                         float sy,
+                                         float ex,
+                                         float ey,
+                                         const ColorInt colors[],
+                                         const float stops[],
+                                         size_t count) override
+    {
+        ++shaders;
+        return inner().makeLinearGradient(sx, sy, ex, ey, colors, stops, count);
+    }
+    rcp<RenderShader> makeRadialGradient(float cx,
+                                         float cy,
+                                         float radius,
+                                         const ColorInt colors[],
+                                         const float stops[],
+                                         size_t count) override
+    {
+        ++shaders;
+        return inner().makeRadialGradient(cx, cy, radius, colors, stops, count);
+    }
+    rcp<RenderPath> makeRenderPath(RawPath& path, FillRule rule) override
+    {
+        ++paths;
+        return inner().makeRenderPath(path, rule);
+    }
+    rcp<RenderPath> makeEmptyRenderPath() override
+    {
+        ++paths;
+        return inner().makeEmptyRenderPath();
+    }
+    rcp<RenderPaint> makeRenderPaint() override
+    {
+        ++paints;
+        return inner().makeRenderPaint();
+    }
+    rcp<RenderImage> decodeImage(Span<const uint8_t> bytes) override
+    {
+        return inner().decodeImage(bytes);
+    }
+
+private:
+    // NoOpFactory's overrides are private; the base class view is public.
+    Factory& inner() { return m_inner; }
+    NoOpFactory m_inner;
+};
+} // namespace
+
+TEST_CASE("instance without an override keeps the file factory",
+          "[instance_factory]")
+{
+    auto file = ReadRiveFile("assets/nested_artboard_opacity.riv");
+    auto instance = file->artboard()->instance<ArtboardInstance>();
+    REQUIRE(instance != nullptr);
+    REQUIRE(instance->factory() == file->artboard()->factory());
+}
+
+TEST_CASE("instance override reroutes resource creation", "[instance_factory]")
+{
+    auto file = ReadRiveFile("assets/nested_artboard_opacity.riv");
+    CountingFactory facade;
+    auto instance = file->artboard()->instance<ArtboardInstance>(&facade);
+    REQUIRE(instance != nullptr);
+    REQUIRE(instance->factory() == &facade);
+    // Fill and stroke paints are created during instancing.
+    REQUIRE(facade.paints > 0);
+}
+
+TEST_CASE("nested instances inherit the override factory", "[instance_factory]")
+{
+    auto file = ReadRiveFile("assets/nested_artboard_opacity.riv");
+    CountingFactory facade;
+    auto instance = file->artboard()->instance<ArtboardInstance>(&facade);
+    REQUIRE(instance != nullptr);
+
+    auto nested = instance->find<NestedArtboard>("Nested artboard container");
+    REQUIRE(nested != nullptr);
+    REQUIRE(nested->sourceArtboard() != nullptr);
+    REQUIRE(nested->sourceArtboard()->factory() == &facade);
+}
+
+TEST_CASE("advance and draw allocate nothing on the file factory after an "
+          "override instance",
+          "[instance_factory]")
+{
+    auto file = ReadRiveFile("assets/nested_artboard_opacity.riv");
+    CountingFactory facade;
+    auto instance = file->artboard()->instance<ArtboardInstance>(&facade);
+    REQUIRE(instance != nullptr);
+
+    instance->advance(0.016f);
+    NoOpRenderer renderer;
+    instance->draw(&renderer);
+    // Lazily created shape paths land on the facade, not the file factory.
+    REQUIRE(facade.paths > 0);
+}
diff --git a/tests/unit_tests/runtime/scripting/scripting_canvas_drawing_phase_test.cpp b/tests/unit_tests/runtime/scripting/scripting_canvas_drawing_phase_test.cpp
deleted file mode 100644
index daa5081..0000000
--- a/tests/unit_tests/runtime/scripting/scripting_canvas_drawing_phase_test.cpp
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Copyright 2026 Rive
- */
-
-// Tests for the canvasDrawingPhase gate.  The flag is set by
-// `Artboard::drawCanvases()` via `ScopedCanvasDrawingPhase` and is checked by
-// the Lua bindings that start canvas-level GPU work — `Canvas:beginFrame()`
-// and `GPUCanvas:beginRenderPass()` — so a script can't begin a canvas draw
-// from a non-draw callback (state-machine input handler, Coop event, async
-// completion, …).
-//
-// `Canvas` and `GPUCanvas` are gated behind `RIVE_CANVAS` / `RIVE_ORE` build
-// flags that aren't enabled in the unit-test build, so we can't exercise
-// those bindings directly here.  Instead we cover the gate at the C++ level
-// via the `ScopedCanvasDrawingPhase` RAII helper, and verify that the
-// non-gated `Artboard:drawCanvas()` binding (which has no GPU dependency)
-// continues to be callable from any phase.
-
-#include "catch.hpp"
-#include "scripting_test_utilities.hpp"
-#include "rive/lua/rive_lua_libs.hpp"
-#include "rive_file_reader.hpp"
-
-using namespace rive;
-
-TEST_CASE("ScopedCanvasDrawingPhase toggles the flag and restores it",
-          "[scripting]")
-{
-    ScriptingTest vm("function noop():() end");
-    lua_State* L = vm.state();
-    auto* context = static_cast<ScriptingContext*>(lua_getthreaddata(L));
-    REQUIRE(context != nullptr);
-
-    // Default state: not in a drawing phase.
-    CHECK(context->canvasDrawingPhase() == false);
-
-    {
-        ScopedCanvasDrawingPhase phase(context);
-        CHECK(context->canvasDrawingPhase() == true);
-
-        // Nested scopes preserve the previous (true) value when they unwind,
-        // so reentrant draws don't accidentally clear the outer phase.
-        {
-            ScopedCanvasDrawingPhase nested(context);
-            CHECK(context->canvasDrawingPhase() == true);
-        }
-        CHECK(context->canvasDrawingPhase() == true);
-    }
-
-    // Restored to the original false after the outer scope unwinds.
-    CHECK(context->canvasDrawingPhase() == false);
-}
-
-TEST_CASE("ScopedCanvasDrawingPhase tolerates a null context", "[scripting]")
-{
-    // Some host code paths (e.g. early init / teardown) may not have a
-    // ScriptingContext yet.  The RAII helper has to be a no-op in that case
-    // rather than crash, since `Artboard::drawCanvases()` constructs it
-    // unconditionally.
-    ScopedCanvasDrawingPhase phase(nullptr);
-    SUCCEED("ScopedCanvasDrawingPhase(nullptr) did not crash");
-}
-
-TEST_CASE("Artboard:drawCanvas() is callable regardless of drawing phase",
-          "[scripting]")
-{
-    // `Artboard:drawCanvas()` itself is not gated — only the canvas-level GPU
-    // entry points (`Canvas:beginFrame()`, `GPUCanvas:beginRenderPass()`) are.
-    // Verify the binding succeeds both inside and outside the drawing phase.
-    // `coin.riv` has no scripted objects so internalDrawCanvases() walks an
-    // empty list and returns cleanly in either case.
-    ScriptingTest vm("function callDrawCanvas(artboard:Artboard):()\n"
-                     "  artboard:drawCanvas()\n"
-                     "end\n");
-    lua_State* L = vm.state();
-    auto* context = static_cast<ScriptingContext*>(lua_getthreaddata(L));
-    REQUIRE(context != nullptr);
-    REQUIRE(context->canvasDrawingPhase() == false);
-
-    auto file = ReadRiveFile("assets/coin.riv", vm.serializer());
-    auto artboard = file->artboard();
-    REQUIRE(artboard != nullptr);
-    lua_newrive<ScriptedArtboard>(L,
-                                  L,
-                                  file.get(),
-                                  artboard->instance(),
-                                  nullptr,
-                                  nullptr);
-
-    // Outside the drawing phase: still succeeds.
-    lua_getglobal(L, "callDrawCanvas");
-    lua_pushvalue(L, -2);
-    CHECK(lua_pcall(L, 1, 0, 0) == LUA_OK);
-
-    // Inside the drawing phase: also succeeds.
-    {
-        ScopedCanvasDrawingPhase phase(context);
-        CHECK(context->canvasDrawingPhase() == true);
-        lua_getglobal(L, "callDrawCanvas");
-        lua_pushvalue(L, -2);
-        CHECK(lua_pcall(L, 1, 0, 0) == LUA_OK);
-    }
-
-    // Phase restored to false after the scope.
-    CHECK(context->canvasDrawingPhase() == false);
-}
diff --git a/tests/unit_tests/runtime/scripting/scripting_gpu_features_test.cpp b/tests/unit_tests/runtime/scripting/scripting_gpu_features_test.cpp
new file mode 100644
index 0000000..63fad59
--- /dev/null
+++ b/tests/unit_tests/runtime/scripting/scripting_gpu_features_test.cpp
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// What a script reads out of context.features while its GPU work is being
+// recorded. The answer has to describe the device that will replay the stream,
+// because a capability branch taken at record time is written into the stream
+// and then replayed verbatim. An answer that is merely plausible is the worst
+// case: replay runs the wrong branch flawlessly on hardware that contradicts
+// it, and nothing downstream can tell.
+//
+// Driven through the ScriptingContext ore override, which is the same hook
+// every host that records uses (riveScriptingUseDeferredSession, the goldens
+// RIVLoader, the editor). The Flutter runtime on this branch never binds a
+// render context, so it is not one of the reachable paths.
+
+#include "catch.hpp"
+#include "scripting_test_utilities.hpp"
+
+#if defined(RIVE_CANVAS) && defined(RIVE_ORE)
+#include "rive/renderer/ore/cmd/ore_deferred_context.hpp"
+#include "rive/renderer/ore/ore_context.hpp"
+
+#include <string>
+
+using namespace rive;
+
+namespace
+{
+// GPU free stand-in for a real backend context. Only what it advertises
+// matters here, so the factories are unreachable.
+class FakeDeviceContext : public ore::Context
+{
+public:
+    FakeDeviceContext() : ore::Context(nullptr) {}
+
+    ore::Features& editableFeatures() { return m_features; }
+
+    rcp<ore::Buffer> makeBuffer(const ore::BufferDesc&) override
+    {
+        return nullptr;
+    }
+    rcp<ore::Texture> makeTexture(const ore::TextureDesc&) override
+    {
+        return nullptr;
+    }
+    rcp<ore::TextureView> makeTextureView(const ore::TextureViewDesc&) override
+    {
+        return nullptr;
+    }
+    rcp<ore::Sampler> makeSampler(const ore::SamplerDesc&) override
+    {
+        return nullptr;
+    }
+    rcp<ore::ShaderModule> makeShaderModule(
+        const ore::ShaderModuleDesc&) override
+    {
+        return nullptr;
+    }
+    rcp<ore::BindGroupLayout> makeBindGroupLayout(
+        const ore::BindGroupLayoutDesc&) override
+    {
+        return nullptr;
+    }
+    rcp<ore::Pipeline> makePipeline(const ore::PipelineDesc&,
+                                    std::string*) override
+    {
+        return nullptr;
+    }
+    rcp<ore::BindGroup> makeBindGroup(const ore::BindGroupDesc&) override
+    {
+        return nullptr;
+    }
+    std::unique_ptr<ore::RenderPass> beginRenderPass(const ore::RenderPassDesc&,
+                                                     std::string*) override
+    {
+        return nullptr;
+    }
+    void beginFrame(const FrameDescriptor&) override {}
+    void endFrame() override {}
+    void waitForGPU() override {}
+    rcp<ore::TextureView> wrapCanvasTexture(gpu::RenderCanvas*) override
+    {
+        return nullptr;
+    }
+    rcp<ore::TextureView> wrapRiveTexture(gpu::Texture*,
+                                          uint32_t,
+                                          uint32_t) override
+    {
+        return nullptr;
+    }
+    ore::ShaderTarget shaderTarget() const override
+    {
+        return ore::ShaderTarget::glsl;
+    }
+};
+
+// context.features reaches this through a namecall on a ScriptedContext bound
+// to a ScriptedObject. Calling it as a bare global is the same entry point
+// without that scaffolding.
+int pushGPUFeatures(lua_State* L) { return lua_push_gpu_features(L); }
+
+// Runs `source` against a VM whose ore context is `ore`, with the features
+// readout exposed as a global. Returns the error message, empty on success.
+std::string runWithOreContext(ore::Context* ore, const char* source)
+{
+    // Deferred execution so the ore override is in place before the chunk
+    // runs, then called here rather than through execute(), which only prints
+    // the message this asserts on.
+    ScriptingTest test(source, 0, true, {}, false);
+    test.vm()->context()->setOreContext(ore);
+    lua_State* L = test.state();
+    lua_pushcfunction(L, pushGPUFeatures, "features");
+    lua_setglobal(L, "gpuFeatures");
+    if (lua_pcall(L, 0, 0, 0) == LUA_OK)
+    {
+        return {};
+    }
+    const char* message = lua_tostring(L, -1);
+    std::string error = message != nullptr ? message : "unknown error";
+    lua_pop(L, 1);
+    return error;
+}
+} // namespace
+
+TEST_CASE("a recording script reads the replay device's capabilities",
+          "[scripting][gpu][features]")
+{
+    FakeDeviceContext device;
+    ore::Features& real = device.editableFeatures();
+    // A device more capable than Features' initializers in both directions: a
+    // flag they deny and a limit they understate.
+    real.colorBufferHalfFloat = true;
+    real.maxSamples = 8;
+
+    SECTION("bound at construction, as every native host binds")
+    {
+        ore::cmd::DeferredOreContext recorder(&device);
+        std::string error = runWithOreContext(
+            &recorder,
+            "local f = gpuFeatures()\n"
+            "assert(f.colorBufferHalfFloat == true, 'half float denied')\n"
+            "assert(f.maxSamples == 8, 'maxSamples ' .. f.maxSamples)\n");
+        CHECK(error.empty());
+    }
+
+    SECTION("bound late, as web binds on attach")
+    {
+        // bindReal used to store the pointer and copy nothing, so a script
+        // running after attach still read the initializers.
+        ore::cmd::DeferredOreContext recorder(nullptr);
+        recorder.bindReal(&device);
+        std::string error = runWithOreContext(
+            &recorder,
+            "local f = gpuFeatures()\n"
+            "assert(f.colorBufferHalfFloat == true, 'half float denied')\n"
+            "assert(f.maxSamples == 8, 'maxSamples ' .. f.maxSamples)\n");
+        CHECK(error.empty());
+    }
+
+    SECTION("a real context answers as it always did")
+    {
+        std::string error = runWithOreContext(
+            &device,
+            "local f = gpuFeatures()\n"
+            "assert(f.maxSamples == 8, 'maxSamples wrong')\n");
+        CHECK(error.empty());
+    }
+}
+
+TEST_CASE("an unbound recording context refuses to report capabilities",
+          "[scripting][gpu][features]")
+{
+    // Refusing rather than answering conservatively, because the conservative
+    // answer is what produces the silent wrong branch: it is indistinguishable
+    // from a real low end device, so no script can defend itself against it,
+    // and the branch it picks is baked into a stream that replays elsewhere.
+    ore::cmd::DeferredOreContext recorder(nullptr);
+    std::string error = runWithOreContext(&recorder, "local f = gpuFeatures()");
+    CHECK(error.find("context.features") != std::string::npos);
+}
+
+TEST_CASE("an undecidable capability gate does not invent a refusal",
+          "[scripting][gpu][features]")
+{
+    // The gates in lua_gpu.cpp are diagnostics; the real backend is the
+    // authority. Gating on the initializers while unbound would reject a
+    // format most devices render, which is the same fiction pointed the other
+    // way -- and unlike a wrong readout it breaks content outright.
+    const char* kScript = "local t = GPUTexture.new({ width = 4, height = 4, "
+                          "format = 'rgba16float', renderTarget = true })";
+
+    SECTION("unbound, the float renderTarget gate stays out of it")
+    {
+        ore::cmd::DeferredOreContext recorder(nullptr);
+        std::string error = runWithOreContext(&recorder, kScript);
+        CHECK(error.find("colorBufferHalfFloat") == std::string::npos);
+    }
+
+    SECTION("bound to a half float device, the gate does not fire")
+    {
+        FakeDeviceContext device;
+        device.editableFeatures().colorBufferHalfFloat = true;
+        ore::cmd::DeferredOreContext recorder(&device);
+        std::string error = runWithOreContext(&recorder, kScript);
+        CHECK(error.find("colorBufferHalfFloat") == std::string::npos);
+    }
+
+    SECTION("bound to a device without it, the gate still fires")
+    {
+        FakeDeviceContext device;
+        ore::cmd::DeferredOreContext recorder(&device);
+        std::string error = runWithOreContext(&recorder, kScript);
+        CHECK(error.find("colorBufferHalfFloat") != std::string::npos);
+    }
+}
+#endif
diff --git a/tests/unit_tests/runtime/scripting/scripting_routing_test.cpp b/tests/unit_tests/runtime/scripting/scripting_routing_test.cpp
new file mode 100644
index 0000000..598d887
--- /dev/null
+++ b/tests/unit_tests/runtime/scripting/scripting_routing_test.cpp
@@ -0,0 +1,109 @@
+#include "catch.hpp"
+#include "scripting_test_utilities.hpp"
+#include "rive/lua/rive_lua_libs.hpp"
+#include "rive/renderer/cmd/deferred_canvas_host.hpp"
+#include "rive_file_reader.hpp"
+#include "utils/no_op_factory.hpp"
+
+using namespace rive;
+
+namespace
+{
+class StubCanvasHost : public cmd::DeferredCanvasHost
+{
+public:
+    Renderer* beginCanvasContent(gpu::RenderCanvas*, uint32_t) override
+    {
+        return nullptr;
+    }
+    void endCanvasContent(gpu::RenderCanvas*) override {}
+};
+
+// Import factory shaped like an FFI deferred session: a device is already
+// bound and canvas work must record through the host.
+class BoundSessionFactory : public NoOpFactory
+{
+public:
+    StubCanvasHost host;
+    Factory* renderContext() override { return this; }
+    cmd::DeferredCanvasHost* deferredCanvasHost() override { return &host; }
+};
+
+// Import factory shaped like a web deferred session: no device yet, but the
+// recording host exists from the start.
+class UnboundSessionFactory : public NoOpFactory
+{
+public:
+    StubCanvasHost host;
+    cmd::DeferredCanvasHost* deferredCanvasHost() override { return &host; }
+};
+} // namespace
+
+TEST_CASE("import routing wires the canvas host when the factory has a device",
+          "[scripting]")
+{
+    BoundSessionFactory factory;
+    auto file = ReadRiveFile("assets/script_advance_test.riv", &factory);
+    auto* context = file->scriptingVM()->context();
+    REQUIRE(context != nullptr);
+    // renderContext() reads through a factory fallback, so the router must
+    // not mistake the factory's own device for a caller-chosen one and skip
+    // the host, which has no fallback of its own.
+    CHECK(context->deferredCanvasHost() == &factory.host);
+    CHECK(context->renderContext() == &factory);
+    CHECK_FALSE(context->renderContextIsLateBound());
+}
+
+TEST_CASE("import routing wires the canvas host before any device exists",
+          "[scripting]")
+{
+    UnboundSessionFactory factory;
+    auto file = ReadRiveFile("assets/script_advance_test.riv", &factory);
+    auto* context = file->scriptingVM()->context();
+    REQUIRE(context != nullptr);
+    CHECK(context->deferredCanvasHost() == &factory.host);
+    // No device: stays late bound so canvas backings defer to whoever binds.
+    CHECK(context->renderContextIsLateBound());
+}
+
+#if defined(RIVE_CANVAS) && defined(RIVE_ORE)
+TEST_CASE("sized canvas construction goes pending until a device binds",
+          "[scripting]")
+{
+    ScriptingTest vm(R"(
+function init(self, context)
+  local gpu = context:gpuCanvas({ width = 4, height = 4 })
+  local c2d = context:canvas({ width = 4, height = 4 })
+  return gpu ~= nil and c2d ~= nil
+end
+)");
+    StubCanvasHost host;
+    vm.vm()->context()->setDeferredCanvasHost(&host);
+
+    ScriptedObjectTest scriptedObjectTest;
+    lua_State* L = vm.state();
+    lua_getglobal(L, "init");
+    lua_pushvalue(L, -2);
+    lua_newrive<ScriptedContext>(L, &scriptedObjectTest);
+    CHECK(lua_pcall(L, 2, 1, 0) == LUA_OK);
+    CHECK(lua_toboolean(L, -1));
+}
+
+TEST_CASE("sized canvas construction still refuses a deviceless factory",
+          "[scripting]")
+{
+    ScriptingTest vm(R"(
+function init(self, context)
+  return context:gpuCanvas({ width = 4, height = 4 })
+end
+)");
+    // No canvas host: this factory will never have a device, so pending
+    // would be a silent forever-hang and the refusal must stay.
+    ScriptedObjectTest scriptedObjectTest;
+    lua_State* L = vm.state();
+    lua_getglobal(L, "init");
+    lua_pushvalue(L, -2);
+    lua_newrive<ScriptedContext>(L, &scriptedObjectTest);
+    CHECK(lua_pcall(L, 2, 1, 0) != LUA_OK);
+}
+#endif
diff --git a/tests/unit_tests/runtime/serialized_replay_test.cpp b/tests/unit_tests/runtime/serialized_replay_test.cpp
new file mode 100644
index 0000000..d704664
--- /dev/null
+++ b/tests/unit_tests/runtime/serialized_replay_test.cpp
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Replays a SerializingFactory stream into a second SerializingFactory and
+// asserts the re-recorded stream is byte identical, proving every call is
+// reproduced in order. GPU free, pixels are covered by the GMs.
+
+#include "utils/serializing_factory.hpp"
+#include "utils/serialized_replay.hpp"
+#include "rive/math/raw_path.hpp"
+#include "rive/math/mat2d.hpp"
+
+#include <catch.hpp>
+#include <cstring>
+
+using namespace rive;
+
+TEST_CASE("serialized 2D commands replay byte-identically",
+          "[serialize][replay]")
+{
+    SerializingFactory a;
+    a.frameSize(256, 256);
+    a.addFrame();
+    auto rendererA = a.makeRenderer();
+
+    // Exercises every paint mutation op.
+    auto paint = a.makeRenderPaint();
+    paint->color(0xFF112233);
+    paint->style(RenderPaintStyle::stroke);
+    paint->thickness(3.5f);
+    paint->join(StrokeJoin::round);
+    paint->cap(StrokeCap::square);
+    paint->blendMode(BlendMode::multiply);
+    paint->feather(2.0f);
+
+    RawPath rp;
+    rp.move({0, 0});
+    rp.line({10, 0});
+    rp.cubic({10, 5}, {5, 10}, {0, 10});
+    rp.close();
+    auto path = a.makeRenderPath(rp, FillRule::evenOdd);
+
+    auto clip = a.makeEmptyRenderPath();
+    RawPath cp;
+    cp.move({0, 0});
+    cp.line({20, 0});
+    cp.line({20, 20});
+    cp.close();
+    clip->addRawPath(cp);
+
+    ColorInt cols[2] = {0xFFFF0000, 0xFF0000FF};
+    float stops[2] = {0.0f, 1.0f};
+    auto grad = a.makeLinearGradient(0, 0, 100, 100, cols, stops, 2);
+    auto paint2 = a.makeRenderPaint();
+    paint2->shader(grad);
+
+    rendererA->save();
+    rendererA->transform(Mat2D(1, 0, 0, 1, 5, 7));
+    rendererA->clipPath(clip.get());
+    rendererA->modulateOpacity(0.5f);
+    rendererA->drawPath(path.get(), paint.get());
+    rendererA->drawPath(path.get(), paint2.get());
+    rendererA->restore();
+
+    SerializingFactory b;
+    auto rendererB = b.makeRenderer();
+    SerializedReplayHooks hooks;
+    hooks.onFrame = [&]() { b.addFrame(); };
+    hooks.onFrameSize = [&](uint32_t w, uint32_t h) { b.frameSize(w, h); };
+    REQUIRE(replaySerializedCommands(a.bytes(), &b, rendererB.get(), hooks));
+
+    auto sa = a.bytes();
+    auto sb = b.bytes();
+    REQUIRE(sa.size() == sb.size());
+    CHECK(std::memcmp(sa.data(), sb.data(), sa.size()) == 0);
+}
+
+TEST_CASE("serialized replay rejects a bad header", "[serialize][replay]")
+{
+    const uint8_t garbage[8] = {'X', 'X', 'X', 'X', 1, 0, 0, 0};
+    SerializingFactory b;
+    auto r = b.makeRenderer();
+    CHECK_FALSE(
+        replaySerializedCommands(Span<const uint8_t>(garbage, sizeof(garbage)),
+                                 &b,
+                                 r.get()));
+}
diff --git a/utils/serialized_replay.cpp b/utils/serialized_replay.cpp
new file mode 100644
index 0000000..dac6df9
--- /dev/null
+++ b/utils/serialized_replay.cpp
@@ -0,0 +1,350 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#include "utils/serialized_replay.hpp"
+#include "utils/serialize_ops.hpp"
+#include "rive/core/binary_reader.hpp"
+#include "rive/math/mat2d.hpp"
+#include <unordered_map>
+#include <vector>
+
+using namespace rive;
+
+namespace
+{
+// Absent ids mean a truncated or corrupt stream; callers fail instead of
+// dereferencing a null resource.
+template <typename T>
+T* find(std::unordered_map<uint64_t, rcp<T>>& map, uint64_t id)
+{
+    auto it = map.find(id);
+    return it != map.end() ? it->second.get() : nullptr;
+}
+} // namespace
+
+bool rive::replaySerializedCommands(Span<const uint8_t> stream,
+                                    Factory* factory,
+                                    Renderer* renderer,
+                                    const SerializedReplayHooks& hooks)
+{
+    BinaryReader reader(stream);
+    if (reader.readByte() != 'S' || reader.readByte() != 'R' ||
+        reader.readByte() != 'I' || reader.readByte() != 'V')
+    {
+        return false;
+    }
+    if (reader.readVarUint64() != 1)
+    {
+        return false;
+    }
+
+    std::unordered_map<uint64_t, rcp<RenderPath>> paths;
+    std::unordered_map<uint64_t, rcp<RenderPaint>> paints;
+    std::unordered_map<uint64_t, rcp<RenderShader>> shaders;
+    std::unordered_map<uint64_t, rcp<RenderImage>> images;
+    std::unordered_map<uint64_t, rcp<RenderBuffer>> buffers;
+
+    while (!reader.reachedEnd())
+    {
+        SerializeOp op = static_cast<SerializeOp>(reader.readVarUint64());
+        if (reader.hasError())
+            return false;
+        switch (op)
+        {
+            case SerializeOp::makeRenderPath:
+            {
+                uint64_t id = reader.readVarUint64();
+                // Geometry and fill rule arrive as later ops.
+                paths[id] = factory->makeEmptyRenderPath();
+                break;
+            }
+            case SerializeOp::makeRenderPaint:
+            {
+                uint64_t id = reader.readVarUint64();
+                paints[id] = factory->makeRenderPaint();
+                break;
+            }
+            case SerializeOp::rewind:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPath* path = find(paths, id);
+                if (path == nullptr)
+                    return false;
+                path->rewind();
+                break;
+            }
+            case SerializeOp::fillRule:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPath* path = find(paths, id);
+                if (path == nullptr)
+                    return false;
+                path->fillRule(static_cast<FillRule>(reader.readVarUint64()));
+                break;
+            }
+            case SerializeOp::addRawPath:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPath* path = find(paths, id);
+                if (path == nullptr)
+                    return false;
+                RawPath rp = deserializeRawPath(reader);
+                path->addRawPath(rp);
+                break;
+            }
+            case SerializeOp::color:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPaint* paint = find(paints, id);
+                if (paint == nullptr)
+                    return false;
+                paint->color(static_cast<unsigned int>(reader.readVarUint64()));
+                break;
+            }
+            case SerializeOp::style:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPaint* paint = find(paints, id);
+                if (paint == nullptr)
+                    return false;
+                // The stream writes 0 for stroke and 1 for fill.
+                bool stroked = reader.readVarUint64() == 0;
+                paint->style(stroked ? RenderPaintStyle::stroke
+                                     : RenderPaintStyle::fill);
+                break;
+            }
+            case SerializeOp::thickness:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPaint* paint = find(paints, id);
+                if (paint == nullptr)
+                    return false;
+                paint->thickness(reader.readFloat32());
+                break;
+            }
+            case SerializeOp::join:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPaint* paint = find(paints, id);
+                if (paint == nullptr)
+                    return false;
+                paint->join(static_cast<StrokeJoin>(reader.readVarUint64()));
+                break;
+            }
+            case SerializeOp::cap:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPaint* paint = find(paints, id);
+                if (paint == nullptr)
+                    return false;
+                paint->cap(static_cast<StrokeCap>(reader.readVarUint64()));
+                break;
+            }
+            case SerializeOp::feather:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPaint* paint = find(paints, id);
+                if (paint == nullptr)
+                    return false;
+                paint->feather(reader.readFloat32());
+                break;
+            }
+            case SerializeOp::blendMode:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderPaint* paint = find(paints, id);
+                if (paint == nullptr)
+                    return false;
+                paint->blendMode(
+                    static_cast<BlendMode>(reader.readVarUint64()));
+                break;
+            }
+            case SerializeOp::shader:
+            {
+                uint64_t id = reader.readVarUint64();
+                uint64_t shaderId = reader.readVarUint64();
+                RenderPaint* paint = find(paints, id);
+                if (paint == nullptr)
+                    return false;
+                // The stream writes 0 for both nullptr and shader id 0. The map
+                // resolves it since a missing entry yields a null rcp.
+                paint->shader(shaders[shaderId]);
+                break;
+            }
+            case SerializeOp::makeLinearGradient:
+            case SerializeOp::makeRadialGradient:
+            {
+                uint64_t id = reader.readVarUint64();
+                size_t count = static_cast<size_t>(reader.readVarUint64());
+                std::vector<ColorInt> colors(count);
+                std::vector<float> stops(count);
+                for (size_t i = 0; i < count; ++i)
+                {
+                    colors[i] = static_cast<ColorInt>(reader.readVarUint64());
+                    stops[i] = reader.readFloat32();
+                }
+                float a = reader.readFloat32();
+                float b = reader.readFloat32();
+                float c = reader.readFloat32();
+                if (op == SerializeOp::makeLinearGradient)
+                {
+                    float d = reader.readFloat32();
+                    shaders[id] = factory->makeLinearGradient(a,
+                                                              b,
+                                                              c,
+                                                              d,
+                                                              colors.data(),
+                                                              stops.data(),
+                                                              count);
+                }
+                else
+                {
+                    shaders[id] = factory->makeRadialGradient(a,
+                                                              b,
+                                                              c,
+                                                              colors.data(),
+                                                              stops.data(),
+                                                              count);
+                }
+                break;
+            }
+            case SerializeOp::decodeImage:
+            {
+                uint64_t id = reader.readVarUint64();
+                size_t size = static_cast<size_t>(reader.readVarUint64());
+                Span<const uint8_t> data = reader.readBytes(size);
+                images[id] = factory->decodeImage(data);
+                break;
+            }
+            case SerializeOp::makeRenderBuffer:
+            {
+                uint64_t id = reader.readVarUint64();
+                size_t size = static_cast<size_t>(reader.readVarUint64());
+                auto type =
+                    static_cast<RenderBufferType>(reader.readVarUint64());
+                auto flags =
+                    static_cast<RenderBufferFlags>(reader.readVarUint64());
+                buffers[id] = factory->makeRenderBuffer(type, flags, size);
+                break;
+            }
+            case SerializeOp::setVertexBufferData:
+            case SerializeOp::setIndexBufferData:
+            {
+                uint64_t id = reader.readVarUint64();
+                RenderBuffer* buf = find(buffers, id);
+                if (buf == nullptr)
+                    return false;
+                void* mapped = buf->map();
+                if (op == SerializeOp::setVertexBufferData)
+                {
+                    size_t n = buf->sizeInBytes() / sizeof(float);
+                    float* out = static_cast<float*>(mapped);
+                    for (size_t i = 0; i < n; ++i)
+                        out[i] = reader.readFloat32();
+                }
+                else
+                {
+                    size_t n = buf->sizeInBytes() / sizeof(uint16_t);
+                    uint16_t* out = static_cast<uint16_t*>(mapped);
+                    for (size_t i = 0; i < n; ++i)
+                        out[i] = static_cast<uint16_t>(reader.readVarUint64());
+                }
+                buf->unmap();
+                break;
+            }
+            case SerializeOp::save:
+                renderer->save();
+                break;
+            case SerializeOp::restore:
+                renderer->restore();
+                break;
+            case SerializeOp::transform:
+            {
+                float m[6];
+                for (int i = 0; i < 6; ++i)
+                    m[i] = reader.readFloat32();
+                renderer->transform(Mat2D(m[0], m[1], m[2], m[3], m[4], m[5]));
+                break;
+            }
+            case SerializeOp::modulateOpacity:
+                renderer->modulateOpacity(reader.readFloat32());
+                break;
+            case SerializeOp::drawPath:
+            {
+                uint64_t pathId = reader.readVarUint64();
+                uint64_t paintId = reader.readVarUint64();
+                RenderPath* path = find(paths, pathId);
+                RenderPaint* paint = find(paints, paintId);
+                if (path == nullptr || paint == nullptr)
+                    return false;
+                renderer->drawPath(path, paint);
+                break;
+            }
+            case SerializeOp::clipPath:
+            {
+                uint64_t pathId = reader.readVarUint64();
+                RenderPath* path = find(paths, pathId);
+                if (path == nullptr)
+                    return false;
+                renderer->clipPath(path);
+                break;
+            }
+            case SerializeOp::drawImage:
+            {
+                uint64_t imageId = reader.readVarUint64();
+                auto blend = static_cast<BlendMode>(reader.readVarUint64());
+                float opacity = reader.readFloat32();
+                renderer->drawImage(images[imageId].get(),
+                                    ImageSampler::LinearClamp(),
+                                    blend,
+                                    opacity);
+                break;
+            }
+            case SerializeOp::drawImageMesh:
+            {
+                uint64_t imageId = reader.readVarUint64();
+                auto blend = static_cast<BlendMode>(reader.readVarUint64());
+                float opacity = reader.readFloat32();
+                rcp<RenderBuffer> pos = buffers[reader.readVarUint64()];
+                rcp<RenderBuffer> uvs = buffers[reader.readVarUint64()];
+                rcp<RenderBuffer> idx = buffers[reader.readVarUint64()];
+                uint32_t vertexCount =
+                    pos ? static_cast<uint32_t>(pos->sizeInBytes() /
+                                                (2 * sizeof(float)))
+                        : 0;
+                uint32_t indexCount =
+                    idx ? static_cast<uint32_t>(idx->sizeInBytes() /
+                                                sizeof(uint16_t))
+                        : 0;
+                renderer->drawImageMesh(images[imageId].get(),
+                                        ImageSampler::LinearClamp(),
+                                        pos,
+                                        uvs,
+                                        idx,
+                                        vertexCount,
+                                        indexCount,
+                                        blend,
+                                        opacity);
+                break;
+            }
+            case SerializeOp::frame:
+                if (hooks.onFrame)
+                    hooks.onFrame();
+                break;
+            case SerializeOp::frameSize:
+            {
+                uint32_t w = static_cast<uint32_t>(reader.readVarUint64());
+                uint32_t h = static_cast<uint32_t>(reader.readVarUint64());
+                if (hooks.onFrameSize)
+                    hooks.onFrameSize(w, h);
+                break;
+            }
+            default:
+                return false; // unknown opcode
+        }
+        if (reader.hasError())
+            return false;
+    }
+    return true;
+}
diff --git a/utils/serializing_factory.cpp b/utils/serializing_factory.cpp
index 4db173c..f52ccb3 100644
--- a/utils/serializing_factory.cpp
+++ b/utils/serializing_factory.cpp
@@ -1,4 +1,5 @@
 #include "utils/serializing_factory.hpp"
+#include "utils/serialize_ops.hpp"
 #include "rive/decoders/bitmap_decoder.hpp"
 #include "rive/core/binary_reader.hpp"
 #include "rive/artboard.hpp"
@@ -13,47 +14,6 @@
 // Threshold for floating point tests.
 static const float epsilon = 0.001f;
 
-enum class SerializeOp : unsigned char
-{
-    makeRenderBuffer = 0,
-    makeLinearGradient = 1,
-    makeRadialGradient = 2,
-    makeRenderPath = 3,
-    makeRenderPaint = 5,
-    decodeImage = 6,
-    save = 7,
-    restore = 8,
-    transform = 9,
-    drawPath = 10,
-    clipPath = 11,
-    drawImage = 12,
-    drawImageMesh = 13,
-
-    // RenderBuffer
-    setVertexBufferData = 14,
-    setIndexBufferData = 15,
-
-    // RenderPath
-    addRawPath = 16,
-    rewind = 17,
-    fillRule = 18,
-
-    // RenderPaint
-    style = 20,
-    color = 21,
-    thickness = 22,
-    join = 23,
-    cap = 24,
-    feather = 25,
-    blendMode = 26,
-    shader = 27,
-
-    frame = 28,
-    frameSize = 29,
-    modulateOpacity = 30,
-
-};
-
 static const char* opToName(SerializeOp op)
 {
     switch (op)
@@ -143,23 +103,6 @@
     uint64_t m_id;
 };
 
-static void serializeRawPath(BinaryWriter* writer, const RawPath& path)
-{
-    auto verbs = path.verbs();
-    auto points = path.points();
-    writer->writeVarUint((uint64_t)verbs.size());
-    for (auto verb : verbs)
-    {
-        writer->writeVarUint((uint64_t)verb);
-    }
-    writer->writeVarUint((uint64_t)points.size());
-    for (auto point : points)
-    {
-        writer->writeFloat(point.x);
-        writer->writeFloat(point.y);
-    }
-}
-
 class SerializingRenderShader : public RenderShader
 {
 public:
@@ -1357,12 +1300,14 @@
 {
     auto fullFileName =
         std::string("silvers/") + std::string(filename) + std::string(".sriv");
+#ifndef NO_GETENV
     const char* rebaseline = getenv("REBASELINE_SILVERS");
     if (rebaseline != nullptr)
     {
         save(fullFileName.c_str());
         return true;
     }
+#endif
 
     FILE* fp = fopen(fullFileName.c_str(), "rb");
     if (fp == nullptr)