Revert the mirror of stale stack commit 3067d371 that undid later master changes
diff --git a/.rive_head b/.rive_head
index 093c3e6..a5e22d6 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-020ea5de4eb86070e28d53b360c721a6cbb51c4c
+fa1cd145d3726e266ff9b9e9a2ff6fd4d6703883
diff --git a/include/rive/audio/audio_engine.hpp b/include/rive/audio/audio_engine.hpp
index a790ff5..5d07884 100644
--- a/include/rive/audio/audio_engine.hpp
+++ b/include/rive/audio/audio_engine.hpp
@@ -57,6 +57,7 @@
                                 Artboard* artboard = nullptr);
 
     static rcp<AudioEngine> RuntimeEngine(bool makeWhenNecessary = true);
+    static void ReleaseRuntimeEngine();
 
 #ifdef EXTERNAL_RIVE_AUDIO_ENGINE
     bool readAudioFrames(float* frames,
diff --git a/include/rive/command_server.hpp b/include/rive/command_server.hpp
index 400fccf..51f1135 100644
--- a/include/rive/command_server.hpp
+++ b/include/rive/command_server.hpp
@@ -48,6 +48,11 @@
                                             const CommandQueue::PointerEvent&);
     rive::HitResult pointerUpSynchronized(StateMachineHandle,
                                           const CommandQueue::PointerEvent&);
+    // Whether a listener under the event's position responds to presses of
+    // its button. Lets a host keep a mouse button for its own use wherever
+    // nothing in the scene wants it.
+    bool listensToButtonAtSynchronized(StateMachineHandle,
+                                       const CommandQueue::PointerEvent&);
 
     File* getFile(FileHandle) const;
     bool getWasDisconnected() const { return m_wasDisconnectReceived; }
diff --git a/include/rive/data_bind/data_values/data_value_color.hpp b/include/rive/data_bind/data_values/data_value_color.hpp
index d204d9a..5acc492 100644
--- a/include/rive/data_bind/data_values/data_value_color.hpp
+++ b/include/rive/data_bind/data_values/data_value_color.hpp
@@ -10,6 +10,12 @@
 {
 private:
     int m_value = 0;
+    void setChannel(int shift, int value)
+    {
+        uint32_t mask = 0xFFu << shift;
+        m_value = (int)(((uint32_t)m_value & ~mask) |
+                        (((uint32_t)value & 0xFFu) << shift));
+    }
 
 public:
     DataValueColor(int value) : m_value(value) {};
@@ -25,10 +31,11 @@
     int red() { return (m_value >> 16) & 0xFF; }
     int green() { return (m_value >> 8) & 0xFF; }
     int blue() { return m_value & 0xFF; }
-    void alpha(int value) { m_value = (m_value & 0x00FFFFFF) | (value << 24); }
-    void red(int value) { m_value = (m_value & 0xFF00FFFF) | (value << 16); }
-    void green(int value) { m_value = (m_value & 0xFFFF00FF) | (value << 8); }
-    void blue(int value) { m_value = (m_value & 0xFFFFFF00) | value; }
+    // Channels keep their low 8 bits, like Color's, so they never spill.
+    void alpha(int value) { setChannel(24, value); }
+    void red(int value) { setChannel(16, value); }
+    void green(int value) { setChannel(8, value); }
+    void blue(int value) { setChannel(0, value); }
     bool compare(DataValue* comparand) override
     {
         if (comparand->is<DataValueColor>())
diff --git a/include/rive/factory.hpp b/include/rive/factory.hpp
index 5afdc5f..5662790 100644
--- a/include/rive/factory.hpp
+++ b/include/rive/factory.hpp
@@ -14,7 +14,6 @@
 
 #include <stdio.h>
 #include <cstdint>
-#include <vector>
 
 namespace rive
 {
@@ -127,7 +126,6 @@
     virtual bool supportsLayerMask() const { return false; }
 
     rcp<Font> decodeFont(Span<const uint8_t>);
-    rcp<Font> decodeFont(std::vector<uint8_t>&&);
 
     rcp<AudioSource> decodeAudio(Span<const uint8_t>);
 
diff --git a/include/rive/focus_data.hpp b/include/rive/focus_data.hpp
index 7d481d9..5646e9e 100644
--- a/include/rive/focus_data.hpp
+++ b/include/rive/focus_data.hpp
@@ -23,6 +23,14 @@
 
     rcp<FocusNode> focusNode();
 
+    void detachFocusable()
+    {
+        if (m_focusNode != nullptr)
+        {
+            m_focusNode->clearFocusable();
+        }
+    }
+
     /// Register a listener to be notified of focus/blur events.
     void addFocusListener(FocusListener* listener);
 
diff --git a/include/rive/generated/core_registry.hpp b/include/rive/generated/core_registry.hpp
index be03434..36d6a3f 100644
--- a/include/rive/generated/core_registry.hpp
+++ b/include/rive/generated/core_registry.hpp
@@ -658,10 +658,10 @@
                 return new ListenerInputTypeEvent();
             case ListenerInputTypeGamepadBase::typeKey:
                 return new ListenerInputTypeGamepad();
-            case ListenerInputTypeKeyboardBase::typeKey:
-                return new ListenerInputTypeKeyboard();
             case ListenerInputTypePointerButtonBase::typeKey:
                 return new ListenerInputTypePointerButton();
+            case ListenerInputTypeKeyboardBase::typeKey:
+                return new ListenerInputTypeKeyboard();
             case ListenerInputTypeTextBase::typeKey:
                 return new ListenerInputTypeText();
             case ListenerInputTypeSemanticBase::typeKey:
@@ -1744,6 +1744,9 @@
             case StrokeBase::joinPropertyKey:
                 object->as<StrokeBase>()->join(value);
                 break;
+            case StrokeBase::positionPropertyKey:
+                object->as<StrokeBase>()->position(value);
+                break;
             case PaintImageBase::imageSamplerFilterPropertyKey:
                 object->as<PaintImageBase>()->imageSamplerFilter(value);
                 break;
@@ -3905,6 +3908,8 @@
                 return object->as<StrokeBase>()->cap();
             case StrokeBase::joinPropertyKey:
                 return object->as<StrokeBase>()->join();
+            case StrokeBase::positionPropertyKey:
+                return object->as<StrokeBase>()->position();
             case PaintImageBase::imageSamplerFilterPropertyKey:
                 return object->as<PaintImageBase>()->imageSamplerFilter();
             case PaintImageBase::imageSamplerWrapXPropertyKey:
@@ -5241,6 +5246,7 @@
             case ColorChannelsBase::colorAlphaPropertyKey:
             case StrokeBase::capPropertyKey:
             case StrokeBase::joinPropertyKey:
+            case StrokeBase::positionPropertyKey:
             case PaintImageBase::imageSamplerFilterPropertyKey:
             case PaintImageBase::imageSamplerWrapXPropertyKey:
             case PaintImageBase::imageSamplerWrapYPropertyKey:
@@ -6190,6 +6196,8 @@
                 return object->is<StrokeBase>();
             case StrokeBase::joinPropertyKey:
                 return object->is<StrokeBase>();
+            case StrokeBase::positionPropertyKey:
+                return object->is<StrokeBase>();
             case PaintImageBase::imageSamplerFilterPropertyKey:
                 return object->is<PaintImageBase>();
             case PaintImageBase::imageSamplerWrapXPropertyKey:
diff --git a/include/rive/generated/shapes/paint/stroke_base.hpp b/include/rive/generated/shapes/paint/stroke_base.hpp
index bb7ec1f..4c301e9 100644
--- a/include/rive/generated/shapes/paint/stroke_base.hpp
+++ b/include/rive/generated/shapes/paint/stroke_base.hpp
@@ -36,12 +36,14 @@
     static const uint16_t capPropertyKey = 48;
     static const uint16_t joinPropertyKey = 49;
     static const uint16_t transformAffectsStrokePropertyKey = 50;
+    static const uint16_t positionPropertyKey = 470;
 
 protected:
     float m_Thickness = 1.0f;
     uint8_t m_Cap = 0;
     uint8_t m_Join = 0;
     bool m_TransformAffectsStroke = true;
+    uint8_t m_Position = 1;
 
 public:
     inline float thickness() const { return m_Thickness; }
@@ -101,6 +103,19 @@
         notifyPropertyChanged(transformAffectsStrokePropertyKey);
     }
 
+    inline uint8_t position() const { return m_Position; }
+    void position(uint8_t value)
+    {
+        if (m_Position == value)
+        {
+            return;
+        }
+        RIVE_EDITOR_CHANGING(positionPropertyKey, &m_Position, &value);
+        m_Position = value;
+        RIVE_EDITOR_CHANGED(positionChanged());
+        notifyPropertyChanged(positionPropertyKey);
+    }
+
     Core* clone() const override;
     void copy(const StrokeBase& object)
     {
@@ -108,6 +123,7 @@
         m_Cap = object.m_Cap;
         m_Join = object.m_Join;
         m_TransformAffectsStroke = object.m_TransformAffectsStroke;
+        m_Position = object.m_Position;
         ShapePaint::copy(object);
     }
 
@@ -127,6 +143,9 @@
             case transformAffectsStrokePropertyKey:
                 m_TransformAffectsStroke = CoreBoolType::deserialize(reader);
                 return true;
+            case positionPropertyKey:
+                m_Position = CoreUintType::deserialize(reader);
+                return true;
         }
         return ShapePaint::deserialize(propertyKey, reader);
     }
@@ -136,6 +155,7 @@
     virtual void capChanged() {}
     virtual void joinChanged() {}
     virtual void transformAffectsStrokeChanged() {}
+    virtual void positionChanged() {}
 #ifdef WITH_RIVE_EDITOR
 #include "editor_native/generated/shapes/paint/stroke_ext.inl"
 #endif
diff --git a/include/rive/lua/rive_lua_libs.hpp b/include/rive/lua/rive_lua_libs.hpp
index 2cd51aa..45e925f 100644
--- a/include/rive/lua/rive_lua_libs.hpp
+++ b/include/rive/lua/rive_lua_libs.hpp
@@ -2663,10 +2663,16 @@
             return;
         }
         lua_Callbacks* cb = lua_callbacks(state);
+        if (m_timedDepth++ == 0)
+        {
+            m_outerInterrupt = cb->interrupt;
+        }
         cb->interrupt = interruptCPP;
         executionTime = std::chrono::steady_clock::now();
     }
 
+    // Restores rather than clears, so a host's own interrupt, like the test
+    // harness budget, outlives any timed call it makes.
     void endTimedExecution(lua_State* state)
     {
         if (m_timeoutMs == 0)
@@ -2674,7 +2680,7 @@
             return;
         }
         lua_Callbacks* cb = lua_callbacks(state);
-        cb->interrupt = nullptr;
+        cb->interrupt = --m_timedDepth == 0 ? m_outerInterrupt : interruptCPP;
     }
 
 #ifdef WITH_RIVE_TOOLS
@@ -2692,6 +2698,8 @@
 
 private:
     int m_timeoutMs = 200;
+    int m_timedDepth = 0;
+    void (*m_outerInterrupt)(lua_State* L, int gc) = nullptr;
 #ifdef WITH_RIVE_TOOLS
     ConsoleCallback m_consoleCallback = nullptr;
     VectorBinaryStream m_consoleBuffer;
diff --git a/include/rive/scripted/script_backend.hpp b/include/rive/scripted/script_backend.hpp
index 9bbb9f8..bac64ae 100644
--- a/include/rive/scripted/script_backend.hpp
+++ b/include/rive/scripted/script_backend.hpp
@@ -187,6 +187,11 @@
     {
         Artboard* artboard = nullptr;
         Mat2D transform;
+
+        // Defined in transition_child_ref.hpp, shared by both backends.
+        void draw(Renderer* renderer) const;
+        float width() const;
+        float height() const;
     };
     /// Transition protocol: self.managesTo, read as a field rather than
     /// called. A script opts out of compositing the incoming child with an
diff --git a/include/rive/scripted/transition_child_ref.hpp b/include/rive/scripted/transition_child_ref.hpp
new file mode 100644
index 0000000..3264cb6
--- /dev/null
+++ b/include/rive/scripted/transition_child_ref.hpp
@@ -0,0 +1,35 @@
+#ifndef _RIVE_TRANSITION_CHILD_REF_HPP_
+#define _RIVE_TRANSITION_CHILD_REF_HPP_
+
+#include "rive/artboard.hpp"
+#include "rive/renderer.hpp"
+#include "rive/scripted/script_backend.hpp"
+
+namespace rive
+{
+// drawInternal ignores isHidden, so a child kept out of the normal draw loop
+// still renders here.
+inline void ScriptBackend::TransitionChildRef::draw(Renderer* renderer) const
+{
+    if (artboard == nullptr)
+    {
+        return;
+    }
+    renderer->save();
+    renderer->transform(transform);
+    artboard->drawInternal(renderer);
+    renderer->restore();
+}
+
+inline float ScriptBackend::TransitionChildRef::width() const
+{
+    return artboard != nullptr ? artboard->width() : 0.0f;
+}
+
+inline float ScriptBackend::TransitionChildRef::height() const
+{
+    return artboard != nullptr ? artboard->height() : 0.0f;
+}
+} // namespace rive
+
+#endif
diff --git a/include/rive/shapes/paint/stroke.hpp b/include/rive/shapes/paint/stroke.hpp
index 010556a..0e07194 100644
--- a/include/rive/shapes/paint/stroke.hpp
+++ b/include/rive/shapes/paint/stroke.hpp
@@ -2,6 +2,7 @@
 #define _RIVE_STROKE_HPP_
 #include "rive/generated/shapes/paint/stroke_base.hpp"
 #include "rive/shapes/path_flags.hpp"
+#include "rive/shapes/paint/stroke_position.hpp"
 namespace rive
 {
 class StrokeEffect;
@@ -19,11 +20,13 @@
     void update(ComponentDirt value) override;
     void invalidateRendering() override;
     ShapePaintType paintType() const override { return ShapePaintType::stroke; }
+    StrokePosition strokePosition() const;
 
 protected:
     void thicknessChanged() override;
     void capChanged() override;
     void joinChanged() override;
+    void positionChanged() override;
 };
 } // namespace rive
 
diff --git a/include/rive/text/font_hb.hpp b/include/rive/text/font_hb.hpp
index 15c5f1c..acc619e 100644
--- a/include/rive/text/font_hb.hpp
+++ b/include/rive/text/font_hb.hpp
@@ -5,7 +5,6 @@
 #include "rive/text_engine.hpp"
 
 #include <unordered_map>
-#include <memory>
 #include <vector>
 
 // Platforms where HBFont::DecodeFile can map a file. Declared here so callers
@@ -23,48 +22,6 @@
 class HBFont : public rive::Font
 {
 public:
-    /** A mapped face for nominal coverage queries, without Rive font
-     * initialization. */
-    class FileProbe
-    {
-    public:
-        FileProbe(const FileProbe&) = delete;
-        FileProbe& operator=(const FileProbe&) = delete;
-        /** Releases the probe's font and its reference to the mapped file. */
-        ~FileProbe();
-        /**
-         * Checks nominal coverage without shaping, metrics, or drawing setup.
-         * @param codepoint Unicode character to look up.
-         * @return Whether the face contains the character, or false after
-         * promotion.
-         */
-        bool hasGlyph(rive::Unichar codepoint) const;
-        /**
-         * Transfers the existing HarfBuzz font and mapping into a Rive font.
-         * @return The initialized font, or nullptr if already promoted.
-         */
-        rive::rcp<rive::Font> makeFont();
-
-    private:
-        friend class HBFont;
-        /** Takes ownership of a HarfBuzz font and enables nominal glyph lookup.
-         */
-        explicit FileProbe(hb_font_t* font);
-        hb_font_t* m_font;
-    };
-
-    /**
-     * Maps a file for coverage queries without initializing Rive font metrics
-     * or drawing. The trusted, stable file restrictions documented on
-     * DecodeFile also apply here.
-     * @param path Stable local font file to map.
-     * @param faceIndex Face within a font collection.
-     * @return An owning probe, or nullptr on detectable failure or unsupported
-     * platforms.
-     */
-    static std::unique_ptr<FileProbe> ProbeFile(const char* path,
-                                                unsigned faceIndex = 0);
-
     // We assume ownership of font!
     HBFont(hb_font_t* font);
     ~HBFont() override;
@@ -95,12 +52,8 @@
     bool hasGlyph(const rive::Unichar) const override;
 
     static rive::rcp<rive::Font> Decode(rive::Span<const uint8_t>);
-    /// Decodes [bytes] in place, keeping them for the font's lifetime instead
-    /// of copying them.
-    static rive::rcp<rive::Font> Decode(std::vector<uint8_t>&& bytes);
 
     /// Decodes the font at [path] by mapping the file rather than copying it.
-    /// [faceIndex] picks a face within a font collection (.ttc).
     ///
     /// Only for trusted, stable local files -- system fonts are the intended
     /// use. The mapping stays live for the font's lifetime, and a null return
@@ -113,8 +66,7 @@
     ///
     /// Returns null where RIVE_HB_FILE_MAPPING is undefined, so callers must
     /// always have a Decode fallback.
-    static rive::rcp<rive::Font> DecodeFile(const char* path,
-                                            unsigned faceIndex = 0);
+    static rive::rcp<rive::Font> DecodeFile(const char* path);
     static rive::rcp<rive::Font> FromSystem(void* systemFont,
                                             bool useSystemShaper,
                                             uint16_t weight,
diff --git a/include/rive/text/text.hpp b/include/rive/text/text.hpp
index 8bf4ff1..6bbc452 100644
--- a/include/rive/text/text.hpp
+++ b/include/rive/text/text.hpp
@@ -165,6 +165,12 @@
     }
 };
 
+// Sweep gradients have no Rive shader and fall back to their first stop.
+void drawColorGlyphLayer(Renderer* renderer,
+                         Factory* factory,
+                         Font::ColorGlyphLayer& layer,
+                         float opacity);
+
 class Text : public TextBase,
              public TextInterface,
              public DataBindListItemConsumer
diff --git a/include/rive/text_engine.hpp b/include/rive/text_engine.hpp
index b00d25d..6e8e46b 100644
--- a/include/rive/text_engine.hpp
+++ b/include/rive/text_engine.hpp
@@ -302,6 +302,7 @@
     {
         float offset;
         ColorInt color;
+        bool isForeground = false;
     };
 
     enum class ColorGlyphPaintType : uint8_t
@@ -328,6 +329,9 @@
         float x0 = 0, y0 = 0, x1 = 0, y1 = 0;
         float r0 = 0, r1 = 0;               // radial
         float startAngle = 0, endAngle = 0; // sweep
+        // Maps the unit circle onto the radial's outer circle in glyph space,
+        // keeping its ellipse under uneven or skewed font transforms.
+        Mat2D radialTransform;
 
         // Image data (only valid when paintType == image).
         // Raw encoded image bytes (e.g. PNG from SBIX/CBDT).
diff --git a/include/rive/wasm/gamepad_wire.hpp b/include/rive/wasm/gamepad_wire.hpp
index 644636f..4d7e32e 100644
--- a/include/rive/wasm/gamepad_wire.hpp
+++ b/include/rive/wasm/gamepad_wire.hpp
@@ -1,7 +1,11 @@
 #ifndef _RIVE_WASM_GAMEPAD_WIRE_HPP_
 #define _RIVE_WASM_GAMEPAD_WIRE_HPP_
 
+#include "rive/animation/listener_invocation.hpp"
+
 #include <cstdint>
+#include <cstring>
+#include <optional>
 
 namespace rive
 {
@@ -31,6 +35,156 @@
     uint32_t axisCount = 0;
 };
 
+/// Flattens a gamepad invocation into the wire; false for other kinds. The
+/// snapshot, null on a disconnect, supplies the floats after the wire.
+inline bool packGamepadWire(const ListenerInvocation& invocation,
+                            GamepadWire& wire,
+                            const GamepadSnapshot*& snapshot)
+{
+    if (const GamepadConnectedInvocation* c = invocation.asGamepadConnected())
+    {
+        wire.kind = GamepadWire::kindConnected;
+        snapshot = &c->snapshot;
+    }
+    else if (const GamepadEventInvocation* e = invocation.asGamepadEvent())
+    {
+        wire.kind = GamepadWire::kindEvent;
+        snapshot = &e->fullState;
+        wire.changeKind = (uint32_t)e->change.kind;
+        wire.changeIndex = e->change.index;
+        wire.changeValue = e->change.value;
+        wire.hasStandardButtonIntent = e->hasStandardButtonIntent ? 1 : 0;
+        wire.standardButton = (uint32_t)e->standardButton;
+        wire.hasStandardAxisIntent = e->hasStandardAxisIntent ? 1 : 0;
+        wire.standardAxis = (uint32_t)e->standardAxis;
+    }
+    else if (const GamepadDisconnectedInvocation* d =
+                 invocation.asGamepadDisconnected())
+    {
+        wire.kind = GamepadWire::kindDisconnected;
+        wire.deviceId = d->deviceId;
+    }
+    else
+    {
+        return false;
+    }
+    if (snapshot != nullptr)
+    {
+        wire.deviceId = snapshot->deviceId;
+        wire.mapping = (uint32_t)snapshot->mapping;
+        wire.buttonMaskLo = (uint32_t)snapshot->buttonMask;
+        wire.buttonMaskHi = (uint32_t)(snapshot->buttonMask >> 32);
+        wire.buttonCount = (uint32_t)snapshot->buttonValues.size();
+        wire.axisCount = (uint32_t)snapshot->axes.size();
+    }
+    return true;
+}
+
+inline uint32_t gamepadPayloadSize(const GamepadWire& wire)
+{
+    return (uint32_t)(sizeof(wire) +
+                      (wire.buttonCount + wire.axisCount) * sizeof(float));
+}
+
+inline void writeGamepadPayload(uint8_t* out,
+                                const GamepadWire& wire,
+                                const GamepadSnapshot* snapshot)
+{
+    memcpy(out, &wire, sizeof(wire));
+    if (snapshot == nullptr)
+    {
+        return;
+    }
+    uint8_t* values = out + sizeof(wire);
+    if (wire.buttonCount != 0)
+    {
+        memcpy(values,
+               snapshot->buttonValues.data(),
+               wire.buttonCount * sizeof(float));
+    }
+    if (wire.axisCount != 0)
+    {
+        memcpy(values + wire.buttonCount * sizeof(float),
+               snapshot->axes.data(),
+               wire.axisCount * sizeof(float));
+    }
+}
+
+/// False when byteCount cannot hold the wire and the floats it declares.
+inline bool decodeGamepadWire(const uint8_t* data,
+                              size_t byteCount,
+                              GamepadWire& wire)
+{
+    if (byteCount < sizeof(wire))
+    {
+        return false;
+    }
+    memcpy(&wire, data, sizeof(wire));
+    uint64_t floatCount = uint64_t(wire.buttonCount) + wire.axisCount;
+    return uint64_t(byteCount) >= sizeof(wire) + floatCount * sizeof(float);
+}
+
+// The payload may sit at any guest address, so floats are copied, not read.
+inline GamepadSnapshot decodeGamepadSnapshot(const GamepadWire& wire,
+                                             const uint8_t* data)
+{
+    GamepadSnapshot snapshot;
+    snapshot.deviceId = wire.deviceId;
+    snapshot.mapping = (GamepadMappingKind)wire.mapping;
+    snapshot.buttonMask =
+        (uint64_t(wire.buttonMaskHi) << 32) | wire.buttonMaskLo;
+    const uint8_t* values = data + sizeof(wire);
+    snapshot.buttonValues.resize(wire.buttonCount);
+    snapshot.axes.resize(wire.axisCount);
+    if (wire.buttonCount != 0)
+    {
+        memcpy(snapshot.buttonValues.data(),
+               values,
+               wire.buttonCount * sizeof(float));
+    }
+    if (wire.axisCount != 0)
+    {
+        memcpy(snapshot.axes.data(),
+               values + wire.buttonCount * sizeof(float),
+               wire.axisCount * sizeof(float));
+    }
+    return snapshot;
+}
+
+inline GamepadEventInvocation decodeGamepadEvent(const GamepadWire& wire,
+                                                 const uint8_t* data)
+{
+    GamepadEventInvocation event;
+    event.fullState = decodeGamepadSnapshot(wire, data);
+    event.change.kind = (GamepadInputChangeKind)wire.changeKind;
+    event.change.index = (uint8_t)wire.changeIndex;
+    event.change.value = wire.changeValue;
+    event.hasStandardButtonIntent = wire.hasStandardButtonIntent != 0;
+    event.standardButton = (StandardGamepadButton)wire.standardButton;
+    event.hasStandardAxisIntent = wire.hasStandardAxisIntent != 0;
+    event.standardAxis = (StandardGamepadAxis)wire.standardAxis;
+    return event;
+}
+
+/// The invocation a decoded wire carries; empty for an unknown kind.
+inline std::optional<ListenerInvocation> decodeGamepadInvocation(
+    const GamepadWire& wire,
+    const uint8_t* data)
+{
+    switch (wire.kind)
+    {
+        case GamepadWire::kindConnected:
+            return ListenerInvocation::gamepadConnected(
+                decodeGamepadSnapshot(wire, data));
+        case GamepadWire::kindEvent:
+            return ListenerInvocation::gamepadEvent(
+                decodeGamepadEvent(wire, data));
+        case GamepadWire::kindDisconnected:
+            return ListenerInvocation::gamepadDisconnected(wire.deviceId);
+    }
+    return std::nullopt;
+}
+
 } // namespace rive
 
 #endif
diff --git a/include/rive/wasm/rive_bindings_v1.h b/include/rive/wasm/rive_bindings_v1.h
index e065559..0f2b917 100644
--- a/include/rive/wasm/rive_bindings_v1.h
+++ b/include/rive/wasm/rive_bindings_v1.h
@@ -222,6 +222,8 @@
 void rive_rt_mark_needs_update(uint32_t object);
 RIVE_BINDING_IMPORT("rive_rt_v1", "budget_exceeded")
 void rive_rt_budget_exceeded(uint32_t ms);
+RIVE_BINDING_IMPORT("rive_rt_v1", "error")
+void rive_rt_error(const char* message, uint32_t length);
 RIVE_BINDING_IMPORT("rive_rt_v1", "debug_enter")
 void rive_rt_debug_enter(uint32_t func, uint32_t line);
 RIVE_BINDING_IMPORT("rive_rt_v1", "debug_line")
@@ -396,6 +398,8 @@
 void rive_artboard_bounds(uint32_t artboard, float* out, uint32_t outCount);
 RIVE_BINDING_IMPORT("rive_artboard_v1", "pointer_event")
 uint32_t rive_artboard_pointer_event(uint32_t artboard, uint32_t kind, uint32_t pointerId, float x, float y);
+RIVE_BINDING_IMPORT("rive_artboard_v1", "gamepad_event")
+uint32_t rive_artboard_gamepad_event(uint32_t artboard, const uint8_t* payload, uint32_t byteCount);
 RIVE_BINDING_IMPORT("rive_artboard_v1", "animation")
 uint32_t rive_artboard_animation(uint32_t artboard, const char* name, uint32_t length);
 RIVE_BINDING_IMPORT("rive_artboard_v1", "animation_release")
@@ -406,6 +410,8 @@
 uint32_t rive_artboard_animation_advance(uint32_t animation, float seconds);
 RIVE_BINDING_IMPORT("rive_artboard_v1", "animation_set_time")
 void rive_artboard_animation_set_time(uint32_t animation, float value, uint32_t mode);
+RIVE_BINDING_IMPORT("rive_artboard_v1", "add_to_path")
+void rive_artboard_add_to_path(uint32_t artboard, uint32_t path, float xx, float xy, float yx, float yy, float tx, float ty);
 RIVE_BINDING_IMPORT("rive_artboard_v1", "node")
 uint32_t rive_artboard_node(uint32_t artboard, const char* name, uint32_t length);
 RIVE_BINDING_IMPORT("rive_artboard_v1", "node_release")
@@ -654,6 +660,8 @@
 void rive_gpu_bind_group_layout_release(uint32_t layout);
 RIVE_BINDING_IMPORT("rive_gpu_v1", "bind_group_layout_from_shader")
 uint32_t rive_gpu_bind_group_layout_from_shader(uint32_t shaderModule, uint32_t groupIndex, const uint32_t* dynamicUBOs, uint32_t dynamicUBOCount);
+RIVE_BINDING_IMPORT("rive_gpu_v1", "bind_group_layout_from_shaders")
+uint32_t rive_gpu_bind_group_layout_from_shaders(uint32_t vertexModule, uint32_t fragmentModule, uint32_t groupIndex, const uint32_t* dynamicUBOs, uint32_t dynamicUBOCount);
 RIVE_BINDING_IMPORT("rive_gpu_v1", "bind_group_new")
 uint32_t rive_gpu_bind_group_new(uint32_t layout, const rive_gpu_bind_group_ubo_v1* ubos, uint32_t uboByteCount, const rive_gpu_bind_group_texture_v1* textures, uint32_t textureByteCount, const rive_gpu_bind_group_sampler_v1* samplers, uint32_t samplerByteCount);
 RIVE_BINDING_IMPORT("rive_gpu_v1", "bind_group_release")
@@ -773,6 +781,18 @@
 RIVE_BINDING_IMPORT("rive_shader_v1", "release")
 void rive_shader_release(uint32_t shader);
 
+// rive_test_v1
+RIVE_BINDING_IMPORT("rive_test_v1", "blob")
+uint32_t rive_test_blob(const char* name, uint32_t nameLength, uint8_t* out, uint32_t outCount);
+
+// rive_transition_v1
+RIVE_BINDING_IMPORT("rive_transition_v1", "child_draw")
+void rive_transition_child_draw(uint32_t child, uint32_t renderer);
+RIVE_BINDING_IMPORT("rive_transition_v1", "child_width")
+float rive_transition_child_width(uint32_t child);
+RIVE_BINDING_IMPORT("rive_transition_v1", "child_height")
+float rive_transition_child_height(uint32_t child);
+
 // rive_renderer_v1
 RIVE_BINDING_IMPORT("rive_renderer_v1", "save")
 void rive_renderer_save(uint32_t renderer);
diff --git a/include/rive/wasm/wasm_scripting_vm.hpp b/include/rive/wasm/wasm_scripting_vm.hpp
index 8557a26..8c2c0a0 100644
--- a/include/rive/wasm/wasm_scripting_vm.hpp
+++ b/include/rive/wasm/wasm_scripting_vm.hpp
@@ -104,6 +104,7 @@
             audioSound,
             drawable,
             text,
+            transitionChild,
             count,
         };
         struct Slot
@@ -121,6 +122,9 @@
         void release(uint32_t handle, Tag tag);
     };
     HandleTable& handles() { return m_handles; }
+    /// Borrowed node handles, released when the path effect update that
+    /// minted them returns.
+    std::vector<uint32_t>& scopedNodes() { return m_scopedNodes; }
 
     // Saves the draw visit in flight has open on the renderer it was handed,
     // so a trap can close them before the rest of the frame draws.
@@ -424,8 +428,30 @@
     /// Ends module execution like a trap once the current native returns.
     virtual void raiseModuleError(const char* message);
 
-    /// The full raiseModuleError text when `exception` is its truncation
-    /// (the runtime's exception buffer is small), otherwise `exception`.
+    /// raiseModuleError for a spent execution budget, which lastTrap marks.
+    void raiseBudgetExceeded();
+
+    /// raiseModuleError for a message the script threw, which lastTrap marks.
+    void raiseThrown(const char* message);
+
+    /// What ended the last call that trapped: the message the script threw,
+    /// else the runtime's own trap text. A test takes only a thrown trap for
+    /// the error it expected.
+    struct Trap
+    {
+        std::string message;
+        bool budget = false;
+        bool thrown = false;
+    };
+    const Trap& lastTrap() const { return m_lastTrap; }
+
+    /// Traps still land in lastTrap but print nothing, for callers that
+    /// expect them.
+    void setQuietTraps(bool quiet) { m_quietTraps = quiet; }
+
+    /// The trap text without the runtime's "Exception: " prefix: the full
+    /// raiseModuleError text when `exception` is its truncation (the
+    /// runtime's exception buffer is small), otherwise `exception`.
     const char* fullTrapMessage(const char* exception) const;
 
     /// Function imports the module declares that no host native resolves.
@@ -441,6 +467,19 @@
     void setTimeoutMs(int ms);
     int timeoutMs() const { return m_timeoutMs; }
 
+    /// Backs a Tests suite's blob(name); empty when the blob is missing,
+    /// which every blob is outside test runs.
+    using TestBlobLookup =
+        std::function<Span<const uint8_t>(const std::string&)>;
+    void setTestBlobs(TestBlobLookup lookup)
+    {
+        m_testBlobs = std::move(lookup);
+    }
+    Span<const uint8_t> testBlob(const std::string& name) const
+    {
+        return m_testBlobs ? m_testBlobs(name) : Span<const uint8_t>();
+    }
+
     /// Budget for VMs created after the call; the host's trust decision for
     /// files whose modules it baked itself (e.g. dangerouslyFast content).
     static void defaultTimeoutMs(int ms) { sm_defaultTimeoutMs = ms; }
@@ -483,6 +522,10 @@
     bool init(Span<const uint8_t> module);
     uint32_t guestString(const char* text, const char* allocator = "malloc");
     void guestFree(uint32_t ptr);
+    // A call scoped handle over a transition child, 0 for an empty one.
+    uint32_t mintTransitionChild(const TransitionChildRef& ref);
+    void releaseTransitionChild(uint32_t handle);
+    void releaseScopedNodes(size_t from);
     // A method name in guest memory: made once per instance when the module
     // can keep it past the frame, else copied for this call alone.
     class GuestName
@@ -526,7 +569,9 @@
     std::vector<std::string> m_unresolvedImports;
     static int sm_defaultTimeoutMs;
     int m_timeoutMs = sm_defaultTimeoutMs;
+    TestBlobLookup m_testBlobs;
     HandleTable m_handles;
+    std::vector<uint32_t> m_scopedNodes;
     /// Canvas frames begun and not ended, as handles with a monotonic token
     /// so a script call's exit reclaims only the frames it began.
     struct OpenCanvasFrame
@@ -570,6 +615,10 @@
     bool m_hostBudget = false;
     /// Set once the watchdog interrupts a call; the module never runs again.
     bool m_poisoned = false;
+    bool m_budgetRaised = false;
+    bool m_thrownRaised = false;
+    bool m_quietTraps = false;
+    Trap m_lastTrap;
     VisitSaves m_visitSaves;
     bool m_frameMinor = false;
     bool m_frameMinorAnnounced = false;
diff --git a/include/utils/serialize_ops.hpp b/include/utils/serialize_ops.hpp
index cb944ce..6bbf7f1 100644
--- a/include/utils/serialize_ops.hpp
+++ b/include/utils/serialize_ops.hpp
@@ -84,6 +84,11 @@
     // named image. Recorded inside the masked layer's canvas bracket, as the
     // last op before it closes.
     applyLayerMask = 42, // imageId, mode
+
+    // RenderPaint::strokePosition. Its own op for the same reason as
+    // additiveness: only written once a paint leaves center, so a stream with
+    // no inside or outside strokes is byte for byte what it was before.
+    strokePosition = 43, // paint id, value
 };
 
 inline void serializeRawPath(BinaryWriter* writer, const RawPath& path)
diff --git a/src/artboard.cpp b/src/artboard.cpp
index 9f6b17f..d0222c2 100644
--- a/src/artboard.cpp
+++ b/src/artboard.cpp
@@ -172,11 +172,19 @@
     // First pass: identify ViewModelInstance and ViewModelInstanceValue
     // objects while memory is valid (before any deletions). Precompute which
     // VMIs should be released so we never dereference pointers after deletes.
-    auto gatherVmObjects = [&](Core* object) {
+    // Also detach FocusData from their nodes: with an adopted manager the
+    // focus tree above wasn't torn down, and deleting a focused node blurs the
+    // chain, which must not call into components already deleted.
+    auto preDeletePass = [&](Core* object) {
         if (object == nullptr || object == this)
         {
             return;
         }
+        if (object->is<FocusData>())
+        {
+            object->as<FocusData>()->detachFocusable();
+            return;
+        }
         if (object->is<ViewModelInstance>())
         {
             vmObjects.insert(object);
@@ -190,11 +198,11 @@
     };
     for (auto object : m_Objects)
     {
-        gatherVmObjects(object);
+        preDeletePass(object);
     }
     for (auto object : m_invalidObjects)
     {
-        gatherVmObjects(object);
+        preDeletePass(object);
     }
 
     auto isVmObject = [&](Core* object) -> bool {
diff --git a/src/audio/audio_engine.cpp b/src/audio/audio_engine.cpp
index 76f038a..118cec1 100644
--- a/src/audio/audio_engine.cpp
+++ b/src/audio/audio_engine.cpp
@@ -549,6 +549,8 @@
     return m_runtimeAudioEngine;
 }
 
+void AudioEngine::ReleaseRuntimeEngine() { m_runtimeAudioEngine = nullptr; }
+
 #ifdef EXTERNAL_RIVE_AUDIO_ENGINE
 bool AudioEngine::readAudioFrames(float* frames,
                                   uint64_t numFrames,
diff --git a/src/command_server.cpp b/src/command_server.cpp
index 0143696..fe68503 100644
--- a/src/command_server.cpp
+++ b/src/command_server.cpp
@@ -569,7 +569,8 @@
             std::unique_lock<std::mutex> lock(stateMachine->m_mutex);
             auto hitResult =
                 stateMachine->instance->pointerDown(pos,
-                                                    pointerEvent.pointerId);
+                                                    pointerEvent.pointerId,
+                                                    pointerEvent.button);
             // Process down-driven state changes before a following pointer up.
             if (hitResult != HitResult::none)
             {
@@ -620,7 +621,9 @@
         {
             std::unique_lock<std::mutex> lock(stateMachine->m_mutex);
             auto hitResult =
-                stateMachine->instance->pointerUp(pos, pointerEvent.pointerId);
+                stateMachine->instance->pointerUp(pos,
+                                                  pointerEvent.pointerId,
+                                                  pointerEvent.button);
             // Process up-driven state changes before any following pointer
             // exit.
             if (hitResult != HitResult::none)
@@ -634,6 +637,29 @@
     return rive::HitResult::none;
 }
 
+bool CommandServer::listensToButtonAtSynchronized(
+    StateMachineHandle handle,
+    const CommandQueue::PointerEvent& pointerEvent)
+{
+    std::unique_lock<std::mutex> accesLock(m_stateMachineAccessMutex);
+    auto it = m_stateMachines.find(handle);
+    if (it != m_stateMachines.end())
+    {
+        auto stateMachine = it->second;
+        accesLock.unlock();
+        auto pos = cursorPosForPointerEvent(stateMachine->instance.get(),
+                                            pointerEvent);
+        {
+            std::unique_lock<std::mutex> lock(stateMachine->m_mutex);
+            return stateMachine->instance->listensToButtonAt(
+                pos,
+                pointerEvent.button);
+        }
+    }
+
+    return false;
+}
+
 File* CommandServer::getFile(FileHandle handle) const
 {
     assert(std::this_thread::get_id() == m_threadID);
@@ -1323,7 +1349,7 @@
                 m_commandQueue->m_byteVectors >> bytes;
                 lock.unlock();
 
-                auto font = factory()->decodeFont(std::move(bytes));
+                auto font = factory()->decodeFont(bytes);
                 if (font)
                 {
                     m_fonts[handle] = std::move(font);
diff --git a/src/factory.cpp b/src/factory.cpp
index 79f3c74..77b0a01 100644
--- a/src/factory.cpp
+++ b/src/factory.cpp
@@ -28,15 +28,6 @@
 #endif
 }
 
-rcp<Font> Factory::decodeFont(std::vector<uint8_t>&& bytes)
-{
-#ifdef WITH_RIVE_TEXT
-    return HBFont::Decode(std::move(bytes));
-#else
-    return nullptr;
-#endif
-}
-
 rcp<AudioSource> Factory::decodeAudio(Span<const uint8_t> span)
 {
 #ifdef WITH_RIVE_AUDIO
diff --git a/src/lua/lua_rive_base.cpp b/src/lua/lua_rive_base.cpp
index fc6fabe..e548fb3 100644
--- a/src/lua/lua_rive_base.cpp
+++ b/src/lua/lua_rive_base.cpp
@@ -22,6 +22,11 @@
             luaL_tolstring(L,
                            i,
                            &l); // convert to string using __tostring et al
+        if (i > 1)
+        {
+            // Same separator as Luau's own print.
+            context->print(Span<const char>("\t", 1));
+        }
         context->print(Span<const char>(s, l));
         lua_pop(L, 1); // pop result
     }
diff --git a/src/lua/lua_transition.cpp b/src/lua/lua_transition.cpp
index 835830b..b848263 100644
--- a/src/lua/lua_transition.cpp
+++ b/src/lua/lua_transition.cpp
@@ -1,36 +1,25 @@
 #ifdef WITH_RIVE_SCRIPTING
 #include "rive/lua/rive_lua_libs.hpp"
-#include "rive/nested_artboard.hpp"
-#include "rive/artboard.hpp"
+#include "rive/scripted/transition_child_ref.hpp"
 
 using namespace rive;
 
 void TransitionChild::draw(Renderer* renderer)
 {
-    // Invalidated (post-frame) or not mounted: nothing to draw.
-    if (m_artboard == nullptr)
-    {
-        return;
-    }
-    // Place the child by its world transform then draw its content. The
-    // transition script has already wrapped this call in renderer state
-    // (opacity / transform / clip) to author the effect. drawInternal does not
-    // consult isHidden(), so a child excluded from the normal draw loop still
-    // renders here.
-    renderer->save();
-    renderer->transform(m_worldTransform);
-    m_artboard->drawInternal(renderer);
-    renderer->restore();
+    ScriptBackend::TransitionChildRef{m_artboard, m_worldTransform}.draw(
+        renderer);
 }
 
 float TransitionChild::width() const
 {
-    return m_artboard != nullptr ? m_artboard->width() : 0.0f;
+    return ScriptBackend::TransitionChildRef{m_artboard, m_worldTransform}
+        .width();
 }
 
 float TransitionChild::height() const
 {
-    return m_artboard != nullptr ? m_artboard->height() : 0.0f;
+    return ScriptBackend::TransitionChildRef{m_artboard, m_worldTransform}
+        .height();
 }
 
 static int transition_child_draw(lua_State* L)
diff --git a/src/shapes/paint/paint_outset.cpp b/src/shapes/paint/paint_outset.cpp
index b79a4b8..a4dec30 100644
--- a/src/shapes/paint/paint_outset.cpp
+++ b/src/shapes/paint/paint_outset.cpp
@@ -19,7 +19,13 @@
     float outset = 0.0f;
     if (stroke.has_value())
     {
-        outset = stroke->thickness * .5f;
+        // A centered stroke straddles the path, reaching half its thickness.
+        // The renderer draws an outside stroke as a centered stroke twice as
+        // thick, clipped to the outside, so it reaches the full thickness (and
+        // its miter twice as far). An inside one is clipped to the path and
+        // reaches nothing past it; half is a safe over-estimate.
+        outset = stroke->thickness *
+                 (stroke->position == StrokePosition::outside ? 1.0f : .5f);
         if (stroke->join == StrokeJoin::miter)
         {
             // Miter joins may be longer than the stroke radius.
@@ -53,14 +59,13 @@
     if (paint->is<Stroke>())
     {
         const Stroke* s = paint->as<Stroke>();
-        // Mirrors Stroke::applyTo, which is what the renderer actually sees.
-        // StrokePosition is deliberately not read: applyTo never sets it, so
-        // the stroke really does straddle the path and thickness/2 is the
-        // reach.
+        // Mirrors Stroke::applyTo, which is what the renderer actually sees,
+        // position included: an outside stroke reaches twice as far.
         StrokeParams params;
         params.thickness = s->thickness();
         params.join = static_cast<StrokeJoin>(s->join());
         params.cap = static_cast<StrokeCap>(s->cap());
+        params.position = s->strokePosition();
         stroke = params;
         // Stroke::pickPath: the local path when the transform affects the
         // stroke, the already-world path otherwise.
diff --git a/src/shapes/paint/stroke.cpp b/src/shapes/paint/stroke.cpp
index 7a37fb3..e15cc77 100644
--- a/src/shapes/paint/stroke.cpp
+++ b/src/shapes/paint/stroke.cpp
@@ -2,6 +2,7 @@
 #include "rive/shapes/paint/stroke.hpp"
 #include "rive/shapes/paint/stroke_cap.hpp"
 #include "rive/shapes/paint/stroke_join.hpp"
+#include "rive/shapes/paint/stroke_position.hpp"
 
 using namespace rive;
 
@@ -16,6 +17,7 @@
     renderPaint->thickness(thickness());
     renderPaint->cap((StrokeCap)cap());
     renderPaint->join((StrokeJoin)join());
+    renderPaint->strokePosition(strokePosition());
     return renderPaint;
 }
 
@@ -25,6 +27,7 @@
     renderPaint->thickness(thickness());
     renderPaint->cap((StrokeCap)cap());
     renderPaint->join((StrokeJoin)join());
+    renderPaint->strokePosition(strokePosition());
     renderPaint->shader(nullptr);
     m_PaintMutator->applyTo(renderPaint, opacityModifier);
 }
@@ -40,6 +43,19 @@
 
 void Stroke::joinChanged() { addDirt(ComponentDirt::Paint); }
 
+void Stroke::positionChanged() { addDirt(ComponentDirt::Paint); }
+
+StrokePosition Stroke::strokePosition() const
+{
+    // An unknown value (e.g. from a newer file) draws centered, which is also
+    // what renderers without stroke position support do.
+    if (position() > (uint8_t)StrokePosition::outside)
+    {
+        return StrokePosition::center;
+    }
+    return (StrokePosition)position();
+}
+
 void Stroke::update(ComponentDirt value)
 {
     Super::update(value);
@@ -66,6 +82,7 @@
         m_RenderPaint->thickness(thickness());
         m_RenderPaint->cap((StrokeCap)cap());
         m_RenderPaint->join((StrokeJoin)join());
+        m_RenderPaint->strokePosition(strokePosition());
     }
 }
 
diff --git a/src/text/font_hb.cpp b/src/text/font_hb.cpp
index 97178e2..047b122 100644
--- a/src/text/font_hb.cpp
+++ b/src/text/font_hb.cpp
@@ -13,6 +13,7 @@
 
 #include "hb.h"
 #include "hb-ot.h"
+#include <algorithm>
 #include <unordered_set>
 
 // harfbuzz's own file loader is compiled out (HB_NO_OPEN/HB_NO_MMAP keep it
@@ -35,55 +36,28 @@
 
 bool rive::Font::gFallbackProcEnabled = true;
 
-namespace
-{
-// Takes ownership of blob, which is null when harfbuzz already released its
-// data.
-hb_font_t* hbFontFromBlob(hb_blob_t* blob, unsigned faceIndex = 0)
-{
-    if (blob == nullptr)
-    {
-        return nullptr;
-    }
-    auto face = hb_face_create_or_fail(blob, faceIndex);
-    hb_blob_destroy(blob);
-    if (face == nullptr)
-    {
-        return nullptr;
-    }
-    auto font = hb_font_create(face);
-    hb_face_destroy(face);
-    return font;
-}
-
-/** Takes ownership of blob and initializes a Rive font from its first face. */
-rive::rcp<rive::Font> fontFromBlob(hb_blob_t* blob)
-{
-    auto* font = hbFontFromBlob(blob);
-    return font ? rive::rcp<rive::Font>(new HBFont(font)) : nullptr;
-}
-} // namespace
-
 rive::rcp<rive::Font> HBFont::Decode(rive::Span<const uint8_t> span)
 {
-    return fontFromBlob(hb_blob_create_or_fail((const char*)span.data(),
-                                               (unsigned)span.size(),
-                                               HB_MEMORY_MODE_DUPLICATE,
-                                               nullptr,
-                                               nullptr));
-}
-
-rive::rcp<rive::Font> HBFont::Decode(std::vector<uint8_t>&& bytes)
-{
-    auto* owned = new std::vector<uint8_t>(std::move(bytes));
-    return fontFromBlob(hb_blob_create_or_fail(
-        reinterpret_cast<const char*>(owned->data()),
-        static_cast<unsigned>(owned->size()),
-        HB_MEMORY_MODE_WRITABLE,
-        owned,
-        [](void* context) {
-            delete static_cast<std::vector<uint8_t>*>(context);
-        }));
+    auto blob = hb_blob_create_or_fail((const char*)span.data(),
+                                       (unsigned)span.size(),
+                                       HB_MEMORY_MODE_DUPLICATE,
+                                       nullptr,
+                                       nullptr);
+    if (blob)
+    {
+        auto face = hb_face_create_or_fail(blob, 0);
+        hb_blob_destroy(blob);
+        if (face)
+        {
+            auto font = hb_font_create(face);
+            hb_face_destroy(face);
+            if (font)
+            {
+                return rive::rcp<rive::Font>(new HBFont(font));
+            }
+        }
+    }
+    return nullptr;
 }
 
 #ifdef RIVE_HB_FILE_MAPPING
@@ -104,38 +78,7 @@
 } // namespace
 #endif
 
-HBFont::FileProbe::FileProbe(hb_font_t* font) : m_font(font)
-{
-    hb_ot_font_set_funcs(m_font);
-}
-
-HBFont::FileProbe::~FileProbe() { hb_font_destroy(m_font); }
-
-bool HBFont::FileProbe::hasGlyph(rive::Unichar codepoint) const
-{
-    hb_codepoint_t glyph;
-    return m_font && hb_font_get_nominal_glyph(m_font, codepoint, &glyph);
-}
-
-rive::rcp<rive::Font> HBFont::FileProbe::makeFont()
-{
-    if (!m_font)
-    {
-        return nullptr;
-    }
-    auto* font = m_font;
-    m_font = nullptr; // HBFont now owns the same face and mapped bytes.
-    return rive::rcp<rive::Font>(new HBFont(font));
-}
-
-rive::rcp<rive::Font> HBFont::DecodeFile(const char* path, unsigned faceIndex)
-{
-    auto probe = ProbeFile(path, faceIndex);
-    return probe ? probe->makeFont() : nullptr;
-}
-
-std::unique_ptr<HBFont::FileProbe> HBFont::ProbeFile(const char* path,
-                                                     unsigned faceIndex)
+rive::rcp<rive::Font> HBFont::DecodeFile(const char* path)
 {
 #ifdef RIVE_HB_FILE_MAPPING
     if (path == nullptr)
@@ -166,14 +109,30 @@
 
     // READONLY_MAY_MAKE_WRITABLE: harfbuzz reads our clean, evictable file
     // pages and only copies if it ever needs to write.
-    auto* font = hbFontFromBlob(
+    auto blob =
         hb_blob_create_or_fail(static_cast<const char*>(data),
                                static_cast<unsigned>(size),
                                HB_MEMORY_MODE_READONLY_MAY_MAKE_WRITABLE,
                                new HBFileMapping{data, size},
-                               &destroyHBFileMapping),
-        faceIndex);
-    return font ? std::unique_ptr<FileProbe>(new FileProbe(font)) : nullptr;
+                               &destroyHBFileMapping);
+    // On failure harfbuzz has already invoked the destroy callback.
+    if (blob == nullptr)
+    {
+        return nullptr;
+    }
+    auto face = hb_face_create_or_fail(blob, 0);
+    hb_blob_destroy(blob);
+    if (face == nullptr)
+    {
+        return nullptr;
+    }
+    auto font = hb_font_create(face);
+    hb_face_destroy(face);
+    if (font == nullptr)
+    {
+        return nullptr;
+    }
+    return rive::rcp<rive::Font>(new HBFont(font));
 #else
     return nullptr;
 #endif
@@ -279,13 +238,11 @@
     hb_font_t* font;
     hb_draw_funcs_t* drawFuncs;
     std::vector<rive::Font::ColorGlyphLayer>* layers;
-    rive::GlyphID clipGlyph = 0;
+    rive::RawPath clipPath;
     bool hasClip = false;
     rive::ColorInt foreground = 0xFF000000;
 
-    // Transform stack for gradient coordinates.
-    // HarfBuzz gives gradient coords in the local space of the paint;
-    // transforms map them to glyph space.
+    // HarfBuzz transforms conjugated into Rive glyph space (scaled, Y down).
     std::vector<rive::Mat2D> transformStack;
 
     void pushTransform(float xx,
@@ -295,21 +252,9 @@
                        float dx,
                        float dy)
     {
-        rive::Mat2D m;
-        m[0] = xx;
-        m[1] = yx;
-        m[2] = xy;
-        m[3] = yy;
-        m[4] = dx * gInvScale;
-        m[5] = -dy * gInvScale;
-        if (transformStack.empty())
-        {
-            transformStack.push_back(m);
-        }
-        else
-        {
-            transformStack.push_back(transformStack.back() * m);
-        }
+        rive::Mat2D m(xx, -yx, -xy, yy, dx * gInvScale, -dy * gInvScale);
+        transformStack.push_back(
+            transformStack.empty() ? m : transformStack.back() * m);
     }
 
     void popTransform()
@@ -320,33 +265,37 @@
         }
     }
 
+    rive::Mat2D glyphTransform() const
+    {
+        rive::Mat2D scale = rive::Mat2D::fromScale(gInvScale, -gInvScale);
+        return transformStack.empty() ? scale : transformStack.back() * scale;
+    }
+
     rive::Vec2D mapPoint(float x, float y) const
     {
-        // Scale from HB font units to Rive glyph units, and flip Y.
-        float rx = x * gInvScale;
-        float ry = -y * gInvScale;
-        if (!transformStack.empty())
-        {
-            // The transform stack already incorporates gInvScale + Y flip
-            // in pushTransform, so just apply raw coords.
-            const auto& m = transformStack.back();
-            return rive::Vec2D(m[0] * x + m[2] * y + m[4],
-                               m[1] * x + m[3] * y + m[5]);
-        }
-        return rive::Vec2D(rx, ry);
+        return glyphTransform() * rive::Vec2D(x, y);
     }
 
     float mapRadius(float r) const
     {
         float scaled = r * gInvScale;
+        if (transformStack.empty())
+        {
+            return scaled;
+        }
+        const auto& m = transformStack.back();
+        return scaled * std::sqrt(m[0] * m[0] + m[1] * m[1]);
+    }
+
+    void pushClipGlyph(hb_codepoint_t glyph)
+    {
+        clipPath.rewind();
+        hb_font_draw_glyph(font, glyph, drawFuncs, &clipPath);
         if (!transformStack.empty())
         {
-            // Use the geometric mean of the scale factors.
-            const auto& m = transformStack.back();
-            float sx = std::sqrt(m[0] * m[0] + m[1] * m[1]);
-            return r * sx; // Don't double-apply gInvScale; it's in the matrix.
+            clipPath.transformInPlace(transformStack.back());
         }
-        return scaled;
+        hasClip = true;
     }
 
     // Helper: extract color stops from hb_color_line_t.
@@ -354,8 +303,8 @@
         hb_color_line_t* colorLine,
         rive::ColorInt foreground)
     {
-        unsigned int count = 0;
-        hb_color_line_get_color_stops(colorLine, 0, &count, nullptr);
+        unsigned int count =
+            hb_color_line_get_color_stops(colorLine, 0, nullptr, nullptr);
         std::vector<hb_color_stop_t> hbStops(count);
         hb_color_line_get_color_stops(colorLine, 0, &count, hbStops.data());
         std::vector<rive::Font::GradientStop> stops;
@@ -364,16 +313,21 @@
         {
             rive::ColorInt c =
                 s.is_foreground ? foreground : hbColorToColorInt(s.color);
-            stops.push_back({s.offset, c});
+            stops.push_back(
+                {std::clamp(s.offset, 0.0f, 1.0f), c, (bool)s.is_foreground});
         }
+        // Variations can reorder stops and renderers expect them ascending.
+        std::stable_sort(
+            stops.begin(),
+            stops.end(),
+            [](const auto& a, const auto& b) { return a.offset < b.offset; });
         return stops;
     }
 
-    // Helper: emit a layer with the current clip glyph path.
     rive::Font::ColorGlyphLayer makeClipLayer() const
     {
         rive::Font::ColorGlyphLayer layer;
-        hb_font_draw_glyph(font, clipGlyph, drawFuncs, &layer.path);
+        layer.path = clipPath;
         return layer;
     }
 };
@@ -402,9 +356,7 @@
                                   hb_font_t*,
                                   void*)
 {
-    auto* state = (PaintState*)paint_data;
-    state->clipGlyph = (rive::GlyphID)glyph;
-    state->hasClip = true;
+    ((PaintState*)paint_data)->pushClipGlyph(glyph);
 }
 
 static void paint_push_clip_rectangle(hb_paint_funcs_t*,
@@ -506,6 +458,9 @@
     layer.y1 = sp1.y;
     layer.r0 = state->mapRadius(radius0);
     layer.r1 = state->mapRadius(radius1);
+    layer.radialTransform =
+        state->glyphTransform() *
+        rive::Mat2D::fromScaleAndTranslation(radius1, radius1, x1, y1);
 
     state->layers->push_back(std::move(layer));
 }
@@ -1000,31 +955,19 @@
     if (cacheIt != m_colorLayerCache.end())
     {
         // Copy from cache, but update foreground colors.
-        for (const auto& cached : cacheIt->second)
+        for (ColorGlyphLayer layer : cacheIt->second)
         {
-            ColorGlyphLayer layer;
-            layer.paintType = cached.paintType;
-            layer.path = cached.path;
-            layer.useForeground = cached.useForeground;
-            layer.color = cached.useForeground ? foreground : cached.color;
-            // Copy gradient data if present.
-            layer.stops = cached.stops;
-            layer.x0 = cached.x0;
-            layer.y0 = cached.y0;
-            layer.x1 = cached.x1;
-            layer.y1 = cached.y1;
-            layer.r0 = cached.r0;
-            layer.r1 = cached.r1;
-            layer.startAngle = cached.startAngle;
-            layer.endAngle = cached.endAngle;
-            // Copy image data if present.
-            layer.imageBytes = cached.imageBytes;
-            layer.imageWidth = cached.imageWidth;
-            layer.imageHeight = cached.imageHeight;
-            layer.imageBearingX = cached.imageBearingX;
-            layer.imageBearingY = cached.imageBearingY;
-            layer.imageExtentX = cached.imageExtentX;
-            layer.imageExtentY = cached.imageExtentY;
+            if (layer.useForeground)
+            {
+                layer.color = foreground;
+            }
+            for (auto& stop : layer.stops)
+            {
+                if (stop.isForeground)
+                {
+                    stop.color = foreground;
+                }
+            }
             out.push_back(std::move(layer));
         }
         return cacheIt->second.size();
diff --git a/src/text/raw_text.cpp b/src/text/raw_text.cpp
index 72f52c2..64ce932 100644
--- a/src/text/raw_text.cpp
+++ b/src/text/raw_text.cpp
@@ -568,12 +568,7 @@
     renderer->transform(transform);
     for (auto& layer : layers)
     {
-        auto renderPath =
-            m_factory->makeRenderPath(layer.path, FillRule::nonZero);
-        auto layerPaint = m_factory->makeRenderPaint();
-        layerPaint->style(RenderPaintStyle::fill);
-        layerPaint->color(layer.color);
-        renderer->drawPath(renderPath.get(), layerPaint.get());
+        drawColorGlyphLayer(renderer, m_factory, layer, 1.0f);
     }
     renderer->restore();
 }
diff --git a/src/text/text.cpp b/src/text/text.cpp
index a10ca06..c8728b7 100644
--- a/src/text/text.cpp
+++ b/src/text/text.cpp
@@ -991,6 +991,71 @@
     }
 }
 
+void rive::drawColorGlyphLayer(Renderer* renderer,
+                               Factory* factory,
+                               Font::ColorGlyphLayer& layer,
+                               float opacity)
+{
+    using PaintType = Font::ColorGlyphPaintType;
+    auto paint = factory->makeRenderPaint();
+    paint->style(RenderPaintStyle::fill);
+    Mat2D toUnitCircle;
+    bool isLinear = layer.paintType == PaintType::linearGradient;
+    bool isRadial = layer.paintType == PaintType::radialGradient &&
+                    layer.radialTransform.invert(&toUnitCircle);
+    if (layer.stops.size() < 2 || (!isLinear && !isRadial))
+    {
+        ColorInt color =
+            layer.paintType == PaintType::solid || layer.stops.empty()
+                ? layer.color
+                : layer.stops.front().color;
+        paint->color(colorModulateOpacity(color, opacity));
+        renderer->drawPath(
+            factory->makeRenderPath(layer.path, FillRule::nonZero).get(),
+            paint.get());
+        return;
+    }
+
+    std::vector<ColorInt> colors;
+    std::vector<float> stops;
+    colors.reserve(layer.stops.size());
+    stops.reserve(layer.stops.size());
+    for (const auto& stop : layer.stops)
+    {
+        colors.push_back(colorModulateOpacity(stop.color, opacity));
+        stops.push_back(stop.offset);
+    }
+    if (isLinear)
+    {
+        paint->shader(factory->makeLinearGradient(layer.x0,
+                                                  layer.y0,
+                                                  layer.x1,
+                                                  layer.y1,
+                                                  colors.data(),
+                                                  stops.data(),
+                                                  colors.size()));
+        renderer->drawPath(
+            factory->makeRenderPath(layer.path, FillRule::nonZero).get(),
+            paint.get());
+        return;
+    }
+
+    // Radial shaders are circles, so draw in the gradient's own space.
+    paint->shader(factory->makeRadialGradient(0,
+                                              0,
+                                              1,
+                                              colors.data(),
+                                              stops.data(),
+                                              colors.size()));
+    layer.path.transformInPlace(toUnitCircle);
+    renderer->save();
+    renderer->transform(layer.radialTransform);
+    renderer->drawPath(
+        factory->makeRenderPath(layer.path, FillRule::nonZero).get(),
+        paint.get());
+    renderer->restore();
+}
+
 void Text::drawColorGlyph(Renderer* renderer,
                           const TextDrawCommand::ColorGlyphInfo& info,
                           const Mat2D& worldTransform)
@@ -1043,12 +1108,7 @@
         }
         else
         {
-            auto renderPath =
-                factory->makeRenderPath(layer.path, FillRule::nonZero);
-            auto renderPaint = factory->makeRenderPaint();
-            renderPaint->style(RenderPaintStyle::fill);
-            renderPaint->color(colorModulateOpacity(layer.color, info.opacity));
-            renderer->drawPath(renderPath.get(), renderPaint.get());
+            drawColorGlyphLayer(renderer, factory, layer, info.opacity);
         }
     }
     renderer->restore();
diff --git a/src/viewmodel/runtime/viewmodel_instance_color_runtime.cpp b/src/viewmodel/runtime/viewmodel_instance_color_runtime.cpp
index 76b4506..98c864e 100644
--- a/src/viewmodel/runtime/viewmodel_instance_color_runtime.cpp
+++ b/src/viewmodel/runtime/viewmodel_instance_color_runtime.cpp
@@ -1,5 +1,6 @@
 
 #include "rive/viewmodel/runtime/viewmodel_instance_color_runtime.hpp"
+#include "rive/shapes/paint/color.hpp"
 
 // Default namespace for Rive Cpp code
 using namespace rive;
@@ -35,7 +36,7 @@
 
 void ViewModelInstanceColorRuntime::argb(int a, int r, int g, int b)
 {
-    auto color = (a << 24) | (r << 16) | (g << 8) | b;
+    auto color = colorARGB(a, r, g, b);
     m_viewModelInstanceValue->as<ViewModelInstanceColor>()->propertyValue(
         color);
 }
diff --git a/src/wasm/idl/bindings.py b/src/wasm/idl/bindings.py
index 9d7f45c..6464193 100644
--- a/src/wasm/idl/bindings.py
+++ b/src/wasm/idl/bindings.py
@@ -238,11 +238,14 @@
 ]
 
 
-def op(name, params=(), ret=None, stub=None, guard=None):
+def op(name, params=(), ret=None, stub=None, guard=None, boot=None):
     # guard: the host only carries the op when this macro is defined. Modules
     # import what they call, so one that never calls it links anywhere.
+    # boot: the op traps and module start can reach it, before the exec env
+    # carries the vm, so the WAMR wrapper resolves the booting one. With no
+    # vm at all, as a tier swap reruns start, it traps with this text.
     return {'name': name, 'params': list(params), 'ret': ret, 'stub': stub,
-            'guard': guard}
+            'guard': guard, 'boot': boot}
 
 
 def ns(module, short, ops):
@@ -255,7 +258,10 @@
         op('mark_needs_update', [handle('object')]),
         # Raised by the module's fuel checks when the execution budget
         # passes; the host prints the timeout and traps the op.
-        op('budget_exceeded', [u32('ms')]),
+        op('budget_exceeded', [u32('ms')], boot='execution exceeded timeout'),
+        # A script's throw hands over its message just before it traps, so
+        # the trap reports what was thrown.
+        op('error', [string('message', 'length')], boot='script threw'),
         # Line probes a debug bake plants: function entry and exit keep a
         # shadow call stack, and each line asks whether to stop. line
         # returns nonzero when it did, so the module re-arms its budget.
@@ -434,6 +440,11 @@
             f32('x'),
             f32('y'),
         ], ret='u32'),
+        # artboard:gamepad*: a GamepadWire payload with its button and axis
+        # floats; returns how many scripted drawables took the event.
+        op('gamepad_event', [handle('artboard'),
+                             buf('uint8_t', 'payload', 'byteCount')],
+           ret='u32'),
         op('animation', [handle('artboard'), string('name', 'length')],
            ret='u32'),
         op('animation_release', [handle('animation')]),
@@ -446,6 +457,10 @@
             f32('value'),
             u32('mode'),
         ]),
+        # artboard:addToPath: appends the visible shapes into a host path.
+        op('add_to_path', [handle('artboard'), handle('path'), f32('xx'),
+                           f32('xy'), f32('yx'), f32('yy'), f32('tx'),
+                           f32('ty')]),
         op('node', [handle('artboard'), string('name', 'length')], ret='u32'),
         op('node_release', [handle('node')]),
         # x, y, rotation, scaleX, scaleY.
@@ -804,6 +819,14 @@
             u32('groupIndex'),
             buf('uint32_t', 'dynamicUBOs', 'dynamicUBOCount'),
         ], ret='u32'),
+        # The same over a pipeline whose stages live in two modules, so the
+        # group covers what either stage binds.
+        op('bind_group_layout_from_shaders', [
+            handle('vertexModule'),
+            handle('fragmentModule'),
+            u32('groupIndex'),
+            buf('uint32_t', 'dynamicUBOs', 'dynamicUBOCount'),
+        ], ret='u32'),
         op('bind_group_new', [
             handle('layout'),
             buf('rive_gpu_bind_group_ubo_v1', 'ubos', 'uboByteCount'),
@@ -964,6 +987,20 @@
         ], ret='u32'),
         op('release', [handle('shader')]),
     ]),
+    # Tests suites' blob(name): the full byte count, size then fill, 0 when
+    # the blob is missing or empty; outside test runs every blob is missing.
+    ns('rive_test_v1', 'test', [
+        op('blob', [string('name', 'nameLength'),
+                    mutbuf('uint8_t', 'out', 'outCount')], ret='u32',
+           stub='zero'),
+    ]),
+    # The children a Transition's changed and draw receive, valid for that
+    # call only.
+    ns('rive_transition_v1', 'transition', [
+        op('child_draw', [handle('child'), handle('renderer')]),
+        op('child_width', [handle('child')], ret='f32'),
+        op('child_height', [handle('child')], ret='f32'),
+    ]),
     ns('rive_renderer_v1', 'renderer', [
         op('save', [handle('renderer')]),
         op('restore', [handle('renderer')]),
diff --git a/src/wasm/idl/generate.py b/src/wasm/idl/generate.py
index b9dc89e..b090ac7 100644
--- a/src/wasm/idl/generate.py
+++ b/src/wasm/idl/generate.py
@@ -324,7 +324,17 @@
             call = '%sImpl(%s)' % (name, ', '.join(args))
             body = ['%s %s(%s)' % (ret_ctype(op), name, ', '.join(params)),
                     '{',
-                    '    WasmScriptingVM* vm = vmFromEnv(env);']
+                    '    WasmScriptingVM* vm = %s(env);' %
+                    ('bootVmFromEnv' if op['boot'] else 'vmFromEnv')]
+            if op['boot']:
+                assert op['ret'] is None, op['name']
+                body += ['    if (vm == nullptr)',
+                         '    {',
+                         '        wasm_runtime_set_exception('
+                         'wasm_runtime_get_module_inst(env), "%s");' %
+                         op['boot'],
+                         '        return;',
+                         '    }']
             body += host_bounds_prologue(op)
             body += host_string_prologue(op['params'])
             body += ['    %s%s;' %
diff --git a/src/wasm/wasm_natives_gen.hpp b/src/wasm/wasm_natives_gen.hpp
index 08d3b4e..a737940 100644
--- a/src/wasm/wasm_natives_gen.hpp
+++ b/src/wasm/wasm_natives_gen.hpp
@@ -206,6 +206,7 @@
 void rtLogImpl(WasmScriptingVM* vm, int32_t level, const char* message, uint32_t length);
 void rtMarkNeedsUpdateImpl(WasmScriptingVM* vm, uint32_t object);
 void rtBudgetExceededImpl(WasmScriptingVM* vm, uint32_t ms);
+void rtErrorImpl(WasmScriptingVM* vm, const char* message, uint32_t length);
 void rtDebugEnterImpl(WasmScriptingVM* vm, uint32_t func, uint32_t line);
 uint32_t rtDebugLineImpl(WasmScriptingVM* vm, uint32_t line);
 void rtDebugLeaveImpl(WasmScriptingVM* vm);
@@ -291,11 +292,13 @@
 void artboardSetFrameOriginImpl(WasmScriptingVM* vm, uint32_t artboard, uint32_t value);
 void artboardBoundsImpl(WasmScriptingVM* vm, uint32_t artboard, float* out, uint32_t outCount);
 uint32_t artboardPointerEventImpl(WasmScriptingVM* vm, uint32_t artboard, uint32_t kind, uint32_t pointerId, float x, float y);
+uint32_t artboardGamepadEventImpl(WasmScriptingVM* vm, uint32_t artboard, const uint8_t* payload, uint32_t byteCount);
 uint32_t artboardAnimationImpl(WasmScriptingVM* vm, uint32_t artboard, const char* name, uint32_t length);
 void artboardAnimationReleaseImpl(WasmScriptingVM* vm, uint32_t animation);
 float artboardAnimationDurationImpl(WasmScriptingVM* vm, uint32_t animation);
 uint32_t artboardAnimationAdvanceImpl(WasmScriptingVM* vm, uint32_t animation, float seconds);
 void artboardAnimationSetTimeImpl(WasmScriptingVM* vm, uint32_t animation, float value, uint32_t mode);
+void artboardAddToPathImpl(WasmScriptingVM* vm, uint32_t artboard, uint32_t path, float xx, float xy, float yx, float yy, float tx, float ty);
 uint32_t artboardNodeImpl(WasmScriptingVM* vm, uint32_t artboard, const char* name, uint32_t length);
 void artboardNodeReleaseImpl(WasmScriptingVM* vm, uint32_t node);
 void artboardNodeTransformImpl(WasmScriptingVM* vm, uint32_t node, float* out, uint32_t outCount);
@@ -416,6 +419,7 @@
 uint32_t gpuBindGroupLayoutNewImpl(WasmScriptingVM* vm, uint32_t groupIndex, const rive_gpu_bind_group_layout_entry_v1* entries, uint32_t entryByteCount);
 void gpuBindGroupLayoutReleaseImpl(WasmScriptingVM* vm, uint32_t layout);
 uint32_t gpuBindGroupLayoutFromShaderImpl(WasmScriptingVM* vm, uint32_t shaderModule, uint32_t groupIndex, const uint32_t* dynamicUBOs, uint32_t dynamicUBOCount);
+uint32_t gpuBindGroupLayoutFromShadersImpl(WasmScriptingVM* vm, uint32_t vertexModule, uint32_t fragmentModule, uint32_t groupIndex, const uint32_t* dynamicUBOs, uint32_t dynamicUBOCount);
 uint32_t gpuBindGroupNewImpl(WasmScriptingVM* vm, uint32_t layout, const rive_gpu_bind_group_ubo_v1* ubos, uint32_t uboByteCount, const rive_gpu_bind_group_texture_v1* textures, uint32_t textureByteCount, const rive_gpu_bind_group_sampler_v1* samplers, uint32_t samplerByteCount);
 void gpuBindGroupReleaseImpl(WasmScriptingVM* vm, uint32_t bindGroup);
 uint32_t gpuPipelineNewImpl(WasmScriptingVM* vm, const rive_gpu_pipeline_desc_v1* desc, uint32_t descByteCount, const uint8_t* blob, uint32_t blobCount);
@@ -501,6 +505,10 @@
 uint32_t shaderLinearImpl(WasmScriptingVM* vm, float sx, float sy, float ex, float ey, uint32_t colors, uint32_t stops, uint32_t count);
 uint32_t shaderRadialImpl(WasmScriptingVM* vm, float cx, float cy, float radius, uint32_t colors, uint32_t stops, uint32_t count);
 void shaderReleaseImpl(WasmScriptingVM* vm, uint32_t shader);
+uint32_t testBlobImpl(WasmScriptingVM* vm, const char* name, uint32_t nameLength, uint8_t* out, uint32_t outCount);
+void transitionChildDrawImpl(WasmScriptingVM* vm, uint32_t child, uint32_t renderer);
+float transitionChildWidthImpl(WasmScriptingVM* vm, uint32_t child);
+float transitionChildHeightImpl(WasmScriptingVM* vm, uint32_t child);
 void rendererSaveImpl(WasmScriptingVM* vm, uint32_t renderer);
 void rendererRestoreImpl(WasmScriptingVM* vm, uint32_t renderer);
 void rendererTransformImpl(WasmScriptingVM* vm, uint32_t renderer, float xx, float xy, float yx, float yy, float tx, float ty);
@@ -525,9 +533,25 @@
 }
 void rtBudgetExceeded(wasm_exec_env_t env, uint32_t ms)
 {
-    WasmScriptingVM* vm = vmFromEnv(env);
+    WasmScriptingVM* vm = bootVmFromEnv(env);
+    if (vm == nullptr)
+    {
+        wasm_runtime_set_exception(wasm_runtime_get_module_inst(env), "execution exceeded timeout");
+        return;
+    }
     rtBudgetExceededImpl(vm, ms);
 }
+void rtError(wasm_exec_env_t env, const char* message, uint32_t length)
+{
+    WasmScriptingVM* vm = bootVmFromEnv(env);
+    if (vm == nullptr)
+    {
+        wasm_runtime_set_exception(wasm_runtime_get_module_inst(env), "script threw");
+        return;
+    }
+    WasmStringArg messageUtf8(vm, message, length);
+    rtErrorImpl(vm, messageUtf8.data(), messageUtf8.size());
+}
 void rtDebugEnter(wasm_exec_env_t env, uint32_t func, uint32_t line)
 {
     WasmScriptingVM* vm = vmFromEnv(env);
@@ -981,6 +1005,11 @@
     WasmScriptingVM* vm = vmFromEnv(env);
     return artboardPointerEventImpl(vm, artboard, kind, pointerId, x, y);
 }
+uint32_t artboardGamepadEvent(wasm_exec_env_t env, uint32_t artboard, const uint8_t* payload, uint32_t byteCount)
+{
+    WasmScriptingVM* vm = vmFromEnv(env);
+    return artboardGamepadEventImpl(vm, artboard, payload, byteCount);
+}
 uint32_t artboardAnimation(wasm_exec_env_t env, uint32_t artboard, const char* name, uint32_t length)
 {
     WasmScriptingVM* vm = vmFromEnv(env);
@@ -1007,6 +1036,11 @@
     WasmScriptingVM* vm = vmFromEnv(env);
     artboardAnimationSetTimeImpl(vm, animation, value, mode);
 }
+void artboardAddToPath(wasm_exec_env_t env, uint32_t artboard, uint32_t path, float xx, float xy, float yx, float yy, float tx, float ty)
+{
+    WasmScriptingVM* vm = vmFromEnv(env);
+    artboardAddToPathImpl(vm, artboard, path, xx, xy, yx, yy, tx, ty);
+}
 uint32_t artboardNode(wasm_exec_env_t env, uint32_t artboard, const char* name, uint32_t length)
 {
     WasmScriptingVM* vm = vmFromEnv(env);
@@ -1688,6 +1722,15 @@
     }
     return gpuBindGroupLayoutFromShaderImpl(vm, shaderModule, groupIndex, dynamicUBOs, dynamicUBOCount);
 }
+uint32_t gpuBindGroupLayoutFromShaders(wasm_exec_env_t env, uint32_t vertexModule, uint32_t fragmentModule, uint32_t groupIndex, const uint32_t* dynamicUBOs, uint32_t dynamicUBOCount)
+{
+    WasmScriptingVM* vm = vmFromEnv(env);
+    if (dynamicUBOCount != 0 && !wasm_runtime_validate_native_addr(wasm_runtime_get_module_inst(env), (void*)dynamicUBOs, (uint64_t)dynamicUBOCount * 4))
+    {
+        return {};
+    }
+    return gpuBindGroupLayoutFromShadersImpl(vm, vertexModule, fragmentModule, groupIndex, dynamicUBOs, dynamicUBOCount);
+}
 uint32_t gpuBindGroupNew(wasm_exec_env_t env, uint32_t layout, const rive_gpu_bind_group_ubo_v1* ubos, uint32_t uboByteCount, const rive_gpu_bind_group_texture_v1* textures, uint32_t textureByteCount, const rive_gpu_bind_group_sampler_v1* samplers, uint32_t samplerByteCount)
 {
     WasmScriptingVM* vm = vmFromEnv(env);
@@ -2029,6 +2072,27 @@
     WasmScriptingVM* vm = vmFromEnv(env);
     shaderReleaseImpl(vm, shader);
 }
+uint32_t testBlob(wasm_exec_env_t env, const char* name, uint32_t nameLength, uint8_t* out, uint32_t outCount)
+{
+    WasmScriptingVM* vm = vmFromEnv(env);
+    WasmStringArg nameUtf8(vm, name, nameLength);
+    return testBlobImpl(vm, nameUtf8.data(), nameUtf8.size(), out, outCount);
+}
+void transitionChildDraw(wasm_exec_env_t env, uint32_t child, uint32_t renderer)
+{
+    WasmScriptingVM* vm = vmFromEnv(env);
+    transitionChildDrawImpl(vm, child, renderer);
+}
+float transitionChildWidth(wasm_exec_env_t env, uint32_t child)
+{
+    WasmScriptingVM* vm = vmFromEnv(env);
+    return transitionChildWidthImpl(vm, child);
+}
+float transitionChildHeight(wasm_exec_env_t env, uint32_t child)
+{
+    WasmScriptingVM* vm = vmFromEnv(env);
+    return transitionChildHeightImpl(vm, child);
+}
 void rendererSave(wasm_exec_env_t env, uint32_t renderer)
 {
     WasmScriptingVM* vm = vmFromEnv(env);
@@ -2084,6 +2148,7 @@
     {"log", (void*)rtLog, "(i*~)", nullptr},
     {"mark_needs_update", (void*)rtMarkNeedsUpdate, "(i)", nullptr},
     {"budget_exceeded", (void*)rtBudgetExceeded, "(i)", nullptr},
+    {"error", (void*)rtError, "(*~)", nullptr},
     {"debug_enter", (void*)rtDebugEnter, "(ii)", nullptr},
     {"debug_line", (void*)rtDebugLine, "(i)i", nullptr},
     {"debug_leave", (void*)rtDebugLeave, "()", nullptr},
@@ -2175,11 +2240,13 @@
     {"set_frame_origin", (void*)artboardSetFrameOrigin, "(ii)", nullptr},
     {"bounds", (void*)artboardBounds, "(i*~)", (void*)&wasm_runtime_rive_leaf_native},
     {"pointer_event", (void*)artboardPointerEvent, "(iiiff)i", nullptr},
+    {"gamepad_event", (void*)artboardGamepadEvent, "(i*~)i", nullptr},
     {"animation", (void*)artboardAnimation, "(i*~)i", nullptr},
     {"animation_release", (void*)artboardAnimationRelease, "(i)", nullptr},
     {"animation_duration", (void*)artboardAnimationDuration, "(i)f", nullptr},
     {"animation_advance", (void*)artboardAnimationAdvance, "(if)i", nullptr},
     {"animation_set_time", (void*)artboardAnimationSetTime, "(ifi)", nullptr},
+    {"add_to_path", (void*)artboardAddToPath, "(iiffffff)", nullptr},
     {"node", (void*)artboardNode, "(i*~)i", nullptr},
     {"node_release", (void*)artboardNodeRelease, "(i)", nullptr},
     {"node_transform", (void*)artboardNodeTransform, "(i*~)", (void*)&wasm_runtime_rive_leaf_native},
@@ -2318,6 +2385,7 @@
     {"bind_group_layout_new", (void*)gpuBindGroupLayoutNew, "(i*~)i", nullptr},
     {"bind_group_layout_release", (void*)gpuBindGroupLayoutRelease, "(i)", nullptr},
     {"bind_group_layout_from_shader", (void*)gpuBindGroupLayoutFromShader, "(ii*~)i", nullptr},
+    {"bind_group_layout_from_shaders", (void*)gpuBindGroupLayoutFromShaders, "(iii*~)i", nullptr},
     {"bind_group_new", (void*)gpuBindGroupNew, "(i*~*~*~)i", nullptr},
     {"bind_group_release", (void*)gpuBindGroupRelease, "(i)", nullptr},
     {"pipeline_new", (void*)gpuPipelineNew, "(*~*~)i", nullptr},
@@ -2429,6 +2497,16 @@
     {"release", (void*)shaderRelease, "(i)", (void*)&wasm_runtime_rive_leaf_native},
 };
 
+NativeSymbol kTestNatives[] = {
+    {"blob", (void*)testBlob, "(*~*~)i", nullptr},
+};
+
+NativeSymbol kTransitionNatives[] = {
+    {"child_draw", (void*)transitionChildDraw, "(ii)", nullptr},
+    {"child_width", (void*)transitionChildWidth, "(i)f", nullptr},
+    {"child_height", (void*)transitionChildHeight, "(i)f", nullptr},
+};
+
 NativeSymbol kRendererNatives[] = {
     {"save", (void*)rendererSave, "(i)", (void*)&wasm_runtime_rive_leaf_native},
     {"restore", (void*)rendererRestore, "(i)", (void*)&wasm_runtime_rive_leaf_native},
@@ -2513,6 +2591,14 @@
                kShaderNatives,
                sizeof(kShaderNatives) / sizeof(NativeSymbol)) &&
            wasm_runtime_register_natives(
+               "rive_test_v1",
+               kTestNatives,
+               sizeof(kTestNatives) / sizeof(NativeSymbol)) &&
+           wasm_runtime_register_natives(
+               "rive_transition_v1",
+               kTransitionNatives,
+               sizeof(kTransitionNatives) / sizeof(NativeSymbol)) &&
+           wasm_runtime_register_natives(
                "rive_renderer_v1",
                kRendererNatives,
                sizeof(kRendererNatives) / sizeof(NativeSymbol));
diff --git a/src/wasm/wasm_natives_web_gen.hpp b/src/wasm/wasm_natives_web_gen.hpp
index 5b27c5e..10e282b 100644
--- a/src/wasm/wasm_natives_web_gen.hpp
+++ b/src/wasm/wasm_natives_web_gen.hpp
@@ -34,6 +34,13 @@
     rtBudgetExceededImpl(vm, ms);
 }
 EMSCRIPTEN_KEEPALIVE
+void rive_web_rt_error(uint32_t vmHandle, const char* message, uint32_t length)
+{
+    auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
+    WasmStringArg messageUtf8(vm, message, length);
+    rtErrorImpl(vm, messageUtf8.data(), messageUtf8.size());
+}
+EMSCRIPTEN_KEEPALIVE
 void rive_web_rt_debug_enter(uint32_t vmHandle, uint32_t func, uint32_t line)
 {
     auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
@@ -564,6 +571,12 @@
     return artboardPointerEventImpl(vm, artboard, kind, pointerId, x, y);
 }
 EMSCRIPTEN_KEEPALIVE
+uint32_t rive_web_artboard_gamepad_event(uint32_t vmHandle, uint32_t artboard, const uint8_t* payload, uint32_t byteCount)
+{
+    auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
+    return artboardGamepadEventImpl(vm, artboard, payload, byteCount);
+}
+EMSCRIPTEN_KEEPALIVE
 uint32_t rive_web_artboard_animation(uint32_t vmHandle, uint32_t artboard, const char* name, uint32_t length)
 {
     auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
@@ -595,6 +608,12 @@
     artboardAnimationSetTimeImpl(vm, animation, value, mode);
 }
 EMSCRIPTEN_KEEPALIVE
+void rive_web_artboard_add_to_path(uint32_t vmHandle, uint32_t artboard, uint32_t path, float xx, float xy, float yx, float yy, float tx, float ty)
+{
+    auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
+    artboardAddToPathImpl(vm, artboard, path, xx, xy, yx, yy, tx, ty);
+}
+EMSCRIPTEN_KEEPALIVE
 uint32_t rive_web_artboard_node(uint32_t vmHandle, uint32_t artboard, const char* name, uint32_t length)
 {
     auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
@@ -1310,6 +1329,12 @@
     return gpuBindGroupLayoutFromShaderImpl(vm, shaderModule, groupIndex, dynamicUBOs, dynamicUBOCount);
 }
 EMSCRIPTEN_KEEPALIVE
+uint32_t rive_web_gpu_bind_group_layout_from_shaders(uint32_t vmHandle, uint32_t vertexModule, uint32_t fragmentModule, uint32_t groupIndex, const uint32_t* dynamicUBOs, uint32_t dynamicUBOCount)
+{
+    auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
+    return gpuBindGroupLayoutFromShadersImpl(vm, vertexModule, fragmentModule, groupIndex, dynamicUBOs, dynamicUBOCount);
+}
+EMSCRIPTEN_KEEPALIVE
 uint32_t rive_web_gpu_bind_group_new(uint32_t vmHandle, uint32_t layout, const rive_gpu_bind_group_ubo_v1* ubos, uint32_t uboByteCount, const rive_gpu_bind_group_texture_v1* textures, uint32_t textureByteCount, const rive_gpu_bind_group_sampler_v1* samplers, uint32_t samplerByteCount)
 {
     auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
@@ -1654,6 +1679,31 @@
     shaderReleaseImpl(vm, shader);
 }
 EMSCRIPTEN_KEEPALIVE
+uint32_t rive_web_test_blob(uint32_t vmHandle, const char* name, uint32_t nameLength, uint8_t* out, uint32_t outCount)
+{
+    auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
+    WasmStringArg nameUtf8(vm, name, nameLength);
+    return testBlobImpl(vm, nameUtf8.data(), nameUtf8.size(), out, outCount);
+}
+EMSCRIPTEN_KEEPALIVE
+void rive_web_transition_child_draw(uint32_t vmHandle, uint32_t child, uint32_t renderer)
+{
+    auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
+    transitionChildDrawImpl(vm, child, renderer);
+}
+EMSCRIPTEN_KEEPALIVE
+float rive_web_transition_child_width(uint32_t vmHandle, uint32_t child)
+{
+    auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
+    return transitionChildWidthImpl(vm, child);
+}
+EMSCRIPTEN_KEEPALIVE
+float rive_web_transition_child_height(uint32_t vmHandle, uint32_t child)
+{
+    auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
+    return transitionChildHeightImpl(vm, child);
+}
+EMSCRIPTEN_KEEPALIVE
 void rive_web_renderer_save(uint32_t vmHandle, uint32_t renderer)
 {
     auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
diff --git a/src/wasm/wasm_scripting_vm.cpp b/src/wasm/wasm_scripting_vm.cpp
index d6d6731..1731bd4 100644
--- a/src/wasm/wasm_scripting_vm.cpp
+++ b/src/wasm/wasm_scripting_vm.cpp
@@ -20,6 +20,7 @@
 #include "rive/animation/linear_animation_instance.hpp"
 #include "rive/animation/listener_invocation.hpp"
 #include "rive/animation/state_machine_instance.hpp"
+#include "rive/input/focus_manager.hpp"
 #include "rive/artboard.hpp"
 #include "rive/assets/blob_asset.hpp"
 #include "rive/custom_property_boolean.hpp"
@@ -67,6 +68,7 @@
 #include "rive/math/path_measure.hpp"
 #include "rive/renderer.hpp"
 #include "rive/scripted/scripted_object.hpp"
+#include "rive/scripted/transition_child_ref.hpp"
 #include "rive/viewmodel/viewmodel.hpp"
 #include "rive/viewmodel/viewmodel_instance.hpp"
 #include "rive/viewmodel/viewmodel_instance_boolean.hpp"
@@ -150,7 +152,7 @@
 // host still needs; the sink for that window is parked here.
 thread_local const std::function<void(const char*, size_t)>* s_bootPrint =
     nullptr;
-// The VM whose module is starting, for the debug probes its top level hits.
+// The VM whose module is starting, for the natives its top level reaches.
 thread_local WasmScriptingVM* s_booting = nullptr;
 thread_local WasmScriptingVM::BootHook s_bootHook;
 
@@ -159,6 +161,7 @@
 struct rive::WasmScriptingVMNatives
 {
     static void print(WasmScriptingVM* vm, const char* data, size_t size);
+    static WasmScriptingVM* adoptBooting(wasm_exec_env_t env);
 };
 
 // Loaded modules are immutable and shared: fast-interp translation costs
@@ -391,6 +394,9 @@
     {
         return;
     }
+    // Paths that consume their own traps never reach reportTrap to clear it.
+    vm->m_budgetRaised = false;
+    vm->m_thrownRaised = false;
     // A debugger pauses mid call, so its sessions run unbudgeted.
     if (budgeted && vm->m_hostBudget && vm->m_timeoutMs > 0 &&
         vm->m_debugHooks == nullptr)
@@ -527,20 +533,29 @@
     // A silent fold hides real traps; name them so a script that dies
     // mid-call is diagnosable instead of a mystery no-op.
     const char* exception = fullTrapMessage(wasm_runtime_get_exception(inst));
+    // AOT code traps as unreachable; both tiers name the timeout.
+    m_lastTrap.budget = m_budgetRaised ||
+                        (m_budgetSlot != nullptr && m_budgetSlot->fired.load());
+    m_lastTrap.thrown = m_thrownRaised && !m_lastTrap.budget;
+    m_budgetRaised = false;
+    m_thrownRaised = false;
+    if (m_lastTrap.budget)
+    {
+        exception = "execution exceeded timeout";
+    }
+    m_lastTrap.message = exception != nullptr ? exception : "";
     if (exception == nullptr)
     {
         return;
     }
-    // AOT code traps as unreachable; both tiers name the timeout.
-    if (m_budgetSlot != nullptr && m_budgetSlot->fired.load())
+    if (!m_quietTraps)
     {
-        exception = "execution exceeded timeout";
-    }
-    fprintf(stderr, "wasm call trapped in %s: %s\n", where, exception);
+        fprintf(stderr, "wasm call trapped in %s: %s\n", where, exception);
 #if WASM_ENABLE_DUMP_CALL_STACK != 0
-    wasm_runtime_dump_call_stack(m_state->execEnv);
+        wasm_runtime_dump_call_stack(m_state->execEnv);
 #endif
-    if (m_leakWarningCount > 0 && !m_leakTrapContextPrinted)
+    }
+    if (m_leakWarningCount > 0 && !m_leakTrapContextPrinted && !m_quietTraps)
     {
         // A bare trap after leak warnings is almost always the memory
         // ceiling; say so once for hosts that dropped the warning strings.
@@ -596,6 +611,18 @@
     return wasm_runtime_addr_app_to_native(inst, appAddr);
 }
 
+void WasmScriptingVM::raiseBudgetExceeded()
+{
+    m_budgetRaised = true;
+    raiseModuleError("execution exceeded timeout");
+}
+
+void WasmScriptingVM::raiseThrown(const char* message)
+{
+    m_thrownRaised = true;
+    raiseModuleError(message);
+}
+
 void WasmScriptingVM::raiseModuleError(const char* message)
 {
     // The runtime's exception buffer truncates long messages (shader
@@ -606,9 +633,9 @@
 
 const char* WasmScriptingVM::fullTrapMessage(const char* exception) const
 {
-    if (exception == nullptr || m_moduleErrorDetail.empty())
+    if (exception == nullptr)
     {
-        return exception;
+        return nullptr;
     }
     const char* text = exception;
     constexpr char kPrefix[] = "Exception: ";
@@ -619,12 +646,22 @@
     // Substitute only when the exception is a truncation of the detail, so
     // an unrelated later trap keeps its own message. An empty remainder
     // matches every prefix and must not adopt stale detail.
-    if (*text != '\0' &&
+    if (*text != '\0' && !m_moduleErrorDetail.empty() &&
         m_moduleErrorDetail.compare(0, strlen(text), text) == 0)
     {
         return m_moduleErrorDetail.c_str();
     }
-    return exception;
+    return text;
+}
+
+WasmScriptingVM* WasmScriptingVMNatives::adoptBooting(wasm_exec_env_t env)
+{
+    // Instantiate assigns the same instance on success and null on failure.
+    if (s_booting != nullptr)
+    {
+        s_booting->m_state->instance = wasm_runtime_get_module_inst(env);
+    }
+    return s_booting;
 }
 
 void WasmScriptingVMNatives::print(WasmScriptingVM* vm,
@@ -811,6 +848,14 @@
                                  capacity);
 }
 
+// Module start runs inside wasm_runtime_instantiate, before the exec env
+// carries the vm, so a native that traps there adopts the booting one.
+WasmScriptingVM* bootVmFromEnv(wasm_exec_env_t env)
+{
+    WasmScriptingVM* vm = vmFromEnv(env);
+    return vm != nullptr ? vm : WasmScriptingVMNatives::adoptBooting(env);
+}
+
 // Prototypes, descriptor PODs, and registration tables for the rive_*_v1
 // namespaces come from the binding IDL (src/wasm/idl/bindings.py); the
 // declarations pin each implementation below to the contract signature.
@@ -3215,6 +3260,47 @@
         new HostGpuBindGroupLayout{std::move(layout)});
 }
 
+uint32_t gpuBindGroupLayoutFromShadersImpl(WasmScriptingVM* vm,
+                                           uint32_t vertexModule,
+                                           uint32_t fragmentModule,
+                                           uint32_t groupIndex,
+                                           const uint32_t* dynamicUBOs,
+                                           uint32_t dynamicUBOCount)
+{
+    ore::Context* oreContext = gpuOreContext(vm);
+    if (oreContext == nullptr)
+    {
+        return 0;
+    }
+    auto resolveModule = [vm](uint32_t handle) -> ore::ShaderModule* {
+        auto host = static_cast<HostGpuShaderModule*>(vm->handles().resolve(
+            handle,
+            WasmScriptingVM::HandleTable::Tag::gpuShaderModule));
+        return host != nullptr ? host->shaderModule.get() : nullptr;
+    };
+    ore::ShaderModule* vertex = resolveModule(vertexModule);
+    ore::ShaderModule* fragment = resolveModule(fragmentModule);
+    if (vertex == nullptr || (fragmentModule != 0 && fragment == nullptr))
+    {
+        return 0;
+    }
+    auto layout = ore::makeBindGroupLayoutFromBindingMap(
+        *oreContext,
+        ore::bindingMapForStages(vertex, fragment),
+        groupIndex,
+        dynamicUBOs,
+        dynamicUBOCount,
+        vertex,
+        fragment);
+    if (layout == nullptr)
+    {
+        return 0;
+    }
+    return vm->handles().mint(
+        WasmScriptingVM::HandleTable::Tag::gpuBindGroupLayout,
+        new HostGpuBindGroupLayout{std::move(layout)});
+}
+
 uint32_t gpuBindGroupNewImpl(WasmScriptingVM* vm,
                              uint32_t layoutHandle,
                              const rive_gpu_bind_group_ubo_v1* ubos,
@@ -3675,6 +3761,15 @@
 {
     return 0;
 }
+uint32_t gpuBindGroupLayoutFromShadersImpl(WasmScriptingVM*,
+                                           uint32_t,
+                                           uint32_t,
+                                           uint32_t,
+                                           const uint32_t*,
+                                           uint32_t)
+{
+    return 0;
+}
 uint32_t gpuBindGroupNewImpl(WasmScriptingVM*,
                              uint32_t,
                              const rive_gpu_bind_group_ubo_v1*,
@@ -4073,6 +4168,59 @@
                               WasmScriptingVM::HandleTable::Tag::renderer));
 }
 
+uint32_t testBlobImpl(WasmScriptingVM* vm,
+                      const char* name,
+                      uint32_t nameLength,
+                      uint8_t* out,
+                      uint32_t outCount)
+{
+    if (vm == nullptr)
+    {
+        return 0;
+    }
+    return fillOut(vm->testBlob(std::string(name, nameLength)), out, outCount);
+}
+
+// A transition child handle points at the caller's TransitionChildRef and
+// is released when the call returns.
+const ScriptBackend::TransitionChildRef* resolveTransitionChild(
+    WasmScriptingVM* vm,
+    uint32_t handle)
+{
+    if (vm == nullptr)
+    {
+        return nullptr;
+    }
+    return static_cast<const ScriptBackend::TransitionChildRef*>(
+        vm->handles().resolve(
+            handle,
+            WasmScriptingVM::HandleTable::Tag::transitionChild));
+}
+
+void transitionChildDrawImpl(WasmScriptingVM* vm,
+                             uint32_t child,
+                             uint32_t rendererHandle)
+{
+    auto ref = resolveTransitionChild(vm, child);
+    Renderer* renderer = resolveRenderer(vm, rendererHandle);
+    if (ref != nullptr && renderer != nullptr)
+    {
+        ref->draw(renderer);
+    }
+}
+
+float transitionChildWidthImpl(WasmScriptingVM* vm, uint32_t child)
+{
+    auto ref = resolveTransitionChild(vm, child);
+    return ref != nullptr ? ref->width() : 0.0f;
+}
+
+float transitionChildHeightImpl(WasmScriptingVM* vm, uint32_t child)
+{
+    auto ref = resolveTransitionChild(vm, child);
+    return ref != nullptr ? ref->height() : 0.0f;
+}
+
 void rendererSaveImpl(WasmScriptingVM* vm, uint32_t handle)
 {
     if (auto renderer = resolveRenderer(vm, handle))
@@ -4261,7 +4409,12 @@
             ms);
     // Terminates like a trap when the native returns, so the caller's
     // failed-op handling engages.
-    vm->raiseModuleError("execution exceeded timeout");
+    vm->raiseBudgetExceeded();
+}
+
+void rtErrorImpl(WasmScriptingVM* vm, const char* message, uint32_t length)
+{
+    vm->raiseThrown(std::string(message, length).c_str());
 }
 
 // Unfused multiply-add keeps these bit identical to the module's own f32
@@ -5927,12 +6080,28 @@
 }
 
 // Nodes pin their artboard; the component pointer lives inside its instance.
+// Without an owner the node borrows a path effect's shape for that update.
 struct HostNode
 {
     rcp<HostArtboard> owner;
     TransformComponent* component;
 };
 
+uint32_t mintNode(WasmScriptingVM* vm,
+                  rcp<HostArtboard> owner,
+                  TransformComponent* component)
+{
+    bool borrowed = owner == nullptr;
+    uint32_t handle =
+        vm->handles().mint(WasmScriptingVM::HandleTable::Tag::node,
+                           new HostNode{std::move(owner), component});
+    if (borrowed)
+    {
+        vm->scopedNodes().push_back(handle);
+    }
+    return handle;
+}
+
 uint32_t artboardNodeImpl(WasmScriptingVM* vm,
                           uint32_t handle,
                           const char* name,
@@ -5949,8 +6118,57 @@
     {
         return 0;
     }
-    return vm->handles().mint(WasmScriptingVM::HandleTable::Tag::node,
-                              new HostNode{ref_rcp(host), component});
+    return mintNode(vm, ref_rcp(host), component);
+}
+
+void artboardAddToPathImpl(WasmScriptingVM* vm,
+                           uint32_t handle,
+                           uint32_t pathHandle,
+                           float xx,
+                           float xy,
+                           float yx,
+                           float yy,
+                           float tx,
+                           float ty)
+{
+    auto host = resolveArtboard(vm, handle);
+    auto hostPath = resolvePath(vm, pathHandle);
+    if (host == nullptr || hostPath == nullptr)
+    {
+        return;
+    }
+    Mat2D transform(xx, xy, yx, yy, tx, ty);
+    host->artboard->addToRawPath(hostPath->raw, &transform);
+    hostPath->renderDirty = true;
+}
+
+uint32_t artboardGamepadEventImpl(WasmScriptingVM* vm,
+                                  uint32_t handle,
+                                  const uint8_t* payload,
+                                  uint32_t byteCount)
+{
+    auto host = resolveArtboard(vm, handle);
+    GamepadWire wire;
+    if (host == nullptr || host->stateMachine == nullptr ||
+        !decodeGamepadWire(payload, byteCount, wire))
+    {
+        return 0;
+    }
+    auto invocation = decodeGamepadInvocation(wire, payload);
+    if (!invocation)
+    {
+        return 0;
+    }
+    // The Luau artboard:gamepad* path: focus first, then every scripted
+    // drawable that did not already take it.
+    ScriptedDrawable* dispatched = nullptr;
+    if (auto* focus = host->stateMachine->focusManager())
+    {
+        (void)focus->gamepadDispatch(*invocation, &dispatched);
+    }
+    return (uint32_t)host->stateMachine->broadcastGamepadToScriptedDrawables(
+        *invocation,
+        dispatched);
 }
 
 HostNode* resolveNode(WasmScriptingVM* vm, uint32_t handle)
@@ -5966,7 +6184,8 @@
 void artboardNodeReleaseImpl(WasmScriptingVM* vm, uint32_t handle)
 {
     auto host = resolveNode(vm, handle);
-    if (vm == nullptr || host == nullptr)
+    // A borrowed node is released when its path effect update returns.
+    if (host == nullptr || host->owner == nullptr)
     {
         return;
     }
@@ -5979,11 +6198,18 @@
                                float* out,
                                uint32_t outCount)
 {
-    auto host = resolveNode(vm, handle);
-    if (host == nullptr || outCount < 5)
+    if (outCount < 5)
     {
         return;
     }
+    auto host = resolveNode(vm, handle);
+    // A stale node reads as the identity, the same on every lane.
+    if (host == nullptr)
+    {
+        const float identity[5] = {0, 0, 0, 1, 1};
+        memcpy(out, identity, sizeof(identity));
+        return;
+    }
     out[0] = host->component->x();
     out[1] = host->component->y();
     out[2] = host->component->rotation();
@@ -6061,12 +6287,12 @@
                                     float* out,
                                     uint32_t outCount)
 {
-    auto host = resolveNode(vm, handle);
-    if (host == nullptr || outCount < 6)
+    if (outCount < 6)
     {
         return;
     }
-    const Mat2D& world = host->component->worldTransform();
+    auto host = resolveNode(vm, handle);
+    Mat2D world = host != nullptr ? host->component->worldTransform() : Mat2D();
     for (int i = 0; i < 6; i++)
     {
         out[i] = world[i];
@@ -6215,9 +6441,8 @@
     {
         if (child->is<TransformComponent>())
         {
-            out[index++] = vm->handles().mint(
-                WasmScriptingVM::HandleTable::Tag::node,
-                new HostNode{host->owner, child->as<TransformComponent>()});
+            out[index++] =
+                mintNode(vm, host->owner, child->as<TransformComponent>());
         }
     }
     return count;
@@ -6235,9 +6460,7 @@
     {
         return 0;
     }
-    return vm->handles().mint(
-        WasmScriptingVM::HandleTable::Tag::node,
-        new HostNode{host->owner, parent->as<TransformComponent>()});
+    return mintNode(vm, host->owner, parent->as<TransformComponent>());
 }
 
 // --- rive_data_v1 asset properties (image/font/blob) ------------------------
@@ -8407,7 +8630,10 @@
     s_bootPrint = nullptr;
     if (m_state->instance == nullptr)
     {
-        m_lastError = std::string("module instantiate failed: ") + error;
+        // The runtime's exception buffer truncates what start threw.
+        m_lastError = std::string("module instantiate failed: ") +
+                      (m_moduleErrorDetail.empty() ? std::string(error)
+                                                   : m_moduleErrorDetail);
         return false;
     }
     m_state->execEnv =
@@ -8731,6 +8957,8 @@
             return "animation";
         case Tag::node:
             return "node";
+        case Tag::transitionChild:
+            return "transitionChild";
         case Tag::audioSource:
             return "audioSource";
         case Tag::audioSound:
@@ -9268,19 +9496,49 @@
            sourcePath.verbs().data(),
            verbCount);
     m_pathEffectOut = outPath;
-    uint32_t args[7] = {m_L,
+    // _v2 also gets a node over the shape, the Luau lane's NodeData; modules
+    // built before it get the paint alone.
+    bool withNode =
+        wasm_runtime_lookup_function(m_state->instance,
+                                     "host_obj_path_effect_v2") != nullptr;
+    size_t scopeStart = m_scopedNodes.size();
+    uint32_t nodeHandle = 0;
+    TransformComponent* shape = shapePaint->parentTransformComponent();
+    if (withNode && shape != nullptr)
+    {
+        nodeHandle = mintNode(this, nullptr, shape);
+    }
+    uint32_t args[8] = {m_L,
                         (uint32_t)selfRef,
                         dataPtr + (uint32_t)sizeof(paint) + pointBytes,
                         verbCount,
                         dataPtr + (uint32_t)sizeof(paint),
                         floatCount,
-                        dataPtr};
-    uint32_t ok = callModule("host_obj_path_effect", 7, args);
+                        dataPtr,
+                        nodeHandle};
+    uint32_t ok = withNode ? callModule("host_obj_path_effect_v2", 8, args)
+                           : callModule("host_obj_path_effect", 7, args);
+    releaseScopedNodes(scopeStart);
     m_pathEffectOut = nullptr;
     guestFree(dataPtr);
     return ok != 0;
 }
 
+void WasmScriptingVM::releaseScopedNodes(size_t from)
+{
+    for (size_t i = from; i < m_scopedNodes.size(); i++)
+    {
+        uint32_t handle = m_scopedNodes[i];
+        if (auto node = static_cast<HostNode*>(
+                m_handles.resolve(handle, HandleTable::Tag::node)))
+        {
+            m_handles.release(handle, HandleTable::Tag::node);
+            delete node;
+        }
+    }
+    m_scopedNodes.resize(from);
+}
+
 bool WasmScriptingVM::callDataConvert(ScriptedObject* object,
                                       int selfRef,
                                       const char* method,
@@ -9354,16 +9612,21 @@
                                        float timeStamp,
                                        HitResult* outResult)
 {
-    // Not forwarded yet: host_obj_pointer_event's guest ABI has no slots.
-    (void)hitType;
-    (void)timeStamp;
     if (!valid())
     {
         return false;
     }
+    // _v2 carries the listener type and time stamp; modules built before it
+    // export only the six-argument name, which must be called as such.
     wasm_function_inst_t f =
         wasm_runtime_lookup_function(m_state->instance,
-                                     "host_obj_pointer_event");
+                                     "host_obj_pointer_event_v2");
+    bool legacy = f == nullptr;
+    if (legacy)
+    {
+        f = wasm_runtime_lookup_function(m_state->instance,
+                                         "host_obj_pointer_event");
+    }
     if (f == nullptr)
     {
         return false;
@@ -9374,7 +9637,7 @@
     {
         return false;
     }
-    wasm_val_t args[6];
+    wasm_val_t args[8];
     args[0].kind = WASM_I32;
     args[0].of.i32 = (int32_t)m_L;
     args[1].kind = WASM_I32;
@@ -9387,10 +9650,19 @@
     args[4].of.f64 = localPosition.x;
     args[5].kind = WASM_F64;
     args[5].of.f64 = localPosition.y;
+    args[6].kind = WASM_I32;
+    args[6].of.i32 = (int32_t)hitType;
+    args[7].kind = WASM_F64;
+    args[7].of.f64 = timeStamp;
     wasm_val_t results[1];
     results[0].kind = WASM_I32;
     ScriptCallScope callScope(this);
-    if (!wasm_runtime_call_wasm_a(m_state->execEnv, f, 1, results, 6, args))
+    if (!wasm_runtime_call_wasm_a(m_state->execEnv,
+                                  f,
+                                  1,
+                                  results,
+                                  legacy ? 6 : 8,
+                                  args))
     {
         reportTrap("host_obj_pointer_event");
         return false;
@@ -9451,76 +9723,6 @@
     return result != 0;
 }
 
-// Flattens the gamepad alternatives into the wire layout the module rebuilds
-// snapshots from; false for non-gamepad kinds. Shared by callGamepadEvent and
-// callListenerPerform.
-static bool packGamepadWire(const ListenerInvocation& invocation,
-                            GamepadWire& wire,
-                            const GamepadSnapshot*& snapshot)
-{
-    if (const GamepadConnectedInvocation* c = invocation.asGamepadConnected())
-    {
-        wire.kind = GamepadWire::kindConnected;
-        snapshot = &c->snapshot;
-    }
-    else if (const GamepadEventInvocation* e = invocation.asGamepadEvent())
-    {
-        wire.kind = GamepadWire::kindEvent;
-        snapshot = &e->fullState;
-        wire.changeKind = (uint32_t)e->change.kind;
-        wire.changeIndex = e->change.index;
-        wire.changeValue = e->change.value;
-        wire.hasStandardButtonIntent = e->hasStandardButtonIntent ? 1 : 0;
-        wire.standardButton = (uint32_t)e->standardButton;
-        wire.hasStandardAxisIntent = e->hasStandardAxisIntent ? 1 : 0;
-        wire.standardAxis = (uint32_t)e->standardAxis;
-    }
-    else if (const GamepadDisconnectedInvocation* d =
-                 invocation.asGamepadDisconnected())
-    {
-        wire.kind = GamepadWire::kindDisconnected;
-        wire.deviceId = d->deviceId;
-    }
-    else
-    {
-        return false;
-    }
-    if (snapshot != nullptr)
-    {
-        wire.deviceId = snapshot->deviceId;
-        wire.mapping = (uint32_t)snapshot->mapping;
-        wire.buttonMaskLo = (uint32_t)snapshot->buttonMask;
-        wire.buttonMaskHi = (uint32_t)(snapshot->buttonMask >> 32);
-        wire.buttonCount = (uint32_t)snapshot->buttonValues.size();
-        wire.axisCount = (uint32_t)snapshot->axes.size();
-    }
-    return true;
-}
-
-static void writeGamepadPayload(uint8_t* out,
-                                const GamepadWire& wire,
-                                const GamepadSnapshot* snapshot)
-{
-    memcpy(out, &wire, sizeof(wire));
-    if (snapshot == nullptr)
-    {
-        return;
-    }
-    float* values = (float*)(out + sizeof(wire));
-    if (wire.buttonCount != 0)
-    {
-        memcpy(values,
-               snapshot->buttonValues.data(),
-               wire.buttonCount * sizeof(float));
-    }
-    if (wire.axisCount != 0)
-    {
-        memcpy(values + wire.buttonCount,
-               snapshot->axes.data(),
-               wire.axisCount * sizeof(float));
-    }
-}
-
 bool WasmScriptingVM::callGamepadEvent(ScriptedObject* object,
                                        int selfRef,
                                        const char* method,
@@ -9542,9 +9744,7 @@
     {
         return false;
     }
-    uint32_t byteCount =
-        (uint32_t)(sizeof(wire) +
-                   (wire.buttonCount + wire.axisCount) * sizeof(float));
+    uint32_t byteCount = gamepadPayloadSize(wire);
     uint32_t sizeArgs[1] = {byteCount};
     uint32_t dataPtr = callModule("malloc", 1, sizeArgs);
     if (dataPtr == 0)
@@ -9616,13 +9816,9 @@
         // append their own payload.
         hasGamepad = packGamepadWire(invocation, gamepad, snapshot);
     }
-    uint32_t tailBytes =
-        text != nullptr
-            ? wire.textLength
-            : (hasGamepad ? (uint32_t)(sizeof(gamepad) + (gamepad.buttonCount +
-                                                          gamepad.axisCount) *
-                                                             sizeof(float))
-                          : 0);
+    uint32_t tailBytes = text != nullptr
+                             ? wire.textLength
+                             : (hasGamepad ? gamepadPayloadSize(gamepad) : 0);
     uint32_t byteCount = (uint32_t)sizeof(wire) + tailBytes;
     uint32_t sizeArgs[1] = {byteCount};
     uint32_t dataPtr = callModule("malloc", 1, sizeArgs);
@@ -9645,26 +9841,82 @@
     guestFree(dataPtr);
 }
 
-// The transition protocol has no module-lane wire type for TransitionChild
-// yet, so these hold the host-side defaults: the container composites nothing.
-// Because callTransitionDraw below is a no-op, we must NOT claim to manage the
-// incoming child -- otherwise it would be hidden from the normal draw loop for
-// the duration of every transition and nothing at all would render.
-bool WasmScriptingVM::transitionManagesTo(int selfRef) { return false; }
+// A module predating Transition cannot draw the incoming child, so the
+// runtime keeps drawing it.
+bool WasmScriptingVM::transitionManagesTo(int selfRef)
+{
+    if (!valid() || wasm_runtime_lookup_function(
+                        m_state->instance,
+                        "host_obj_transition_manages_to") == nullptr)
+    {
+        return false;
+    }
+    uint32_t args[2] = {m_L, (uint32_t)selfRef};
+    return callModule("host_obj_transition_manages_to", 2, args) != 0;
+}
+
+uint32_t WasmScriptingVM::mintTransitionChild(const TransitionChildRef& ref)
+{
+    return ref.artboard == nullptr
+               ? 0
+               : m_handles.mint(HandleTable::Tag::transitionChild,
+                                const_cast<TransitionChildRef*>(&ref));
+}
+
+void WasmScriptingVM::releaseTransitionChild(uint32_t handle)
+{
+    if (handle != 0)
+    {
+        m_handles.release(handle, HandleTable::Tag::transitionChild);
+    }
+}
 
 void WasmScriptingVM::callTransitionChanged(ScriptedObject* object,
                                             int selfRef,
                                             const TransitionChildRef& from,
                                             const TransitionChildRef& to,
                                             int direction)
-{}
+{
+    if (!valid())
+    {
+        return;
+    }
+    uint32_t fromHandle = mintTransitionChild(from);
+    uint32_t toHandle = mintTransitionChild(to);
+    uint32_t args[5] = {m_L,
+                        (uint32_t)selfRef,
+                        fromHandle,
+                        toHandle,
+                        (uint32_t)direction};
+    callModule("host_obj_transition_changed", 5, args);
+    releaseTransitionChild(fromHandle);
+    releaseTransitionChild(toHandle);
+}
 
 void WasmScriptingVM::callTransitionDraw(ScriptedObject* object,
                                          int selfRef,
                                          Renderer* renderer,
                                          const TransitionChildRef& from,
                                          const TransitionChildRef& to)
-{}
+{
+    if (!valid())
+    {
+        return;
+    }
+    uint32_t rendererHandle =
+        m_handles.mint(HandleTable::Tag::renderer, renderer);
+    uint32_t fromHandle = mintTransitionChild(from);
+    uint32_t toHandle = mintTransitionChild(to);
+    uint32_t args[5] = {m_L,
+                        (uint32_t)selfRef,
+                        rendererHandle,
+                        fromHandle,
+                        toHandle};
+    callModule("host_obj_transition_draw", 5, args);
+    releaseTransitionChild(fromHandle);
+    releaseTransitionChild(toHandle);
+    m_handles.release(rendererHandle, HandleTable::Tag::renderer);
+}
 
 void WasmScriptingVM::callLayoutResize(ScriptedObject* object,
                                        int selfRef,
diff --git a/src/wasm/web/rive_module_imports.mjs b/src/wasm/web/rive_module_imports.mjs
index 3bd33e9..a22e4d8 100644
--- a/src/wasm/web/rive_module_imports.mjs
+++ b/src/wasm/web/rive_module_imports.mjs
@@ -41,6 +41,11 @@
             },
             mark_needs_update: calls.rive_rt_mark_needs_update,
             budget_exceeded: calls.rive_rt_budget_exceeded,
+            error: (message, length) => {
+                stackReset();
+                const message_p = stageIn(message, length);
+                calls.rive_rt_error(message_p, length);
+            },
             debug_enter: calls.rive_rt_debug_enter,
             debug_line: calls.rive_rt_debug_line,
             debug_leave: calls.rive_rt_debug_leave,
@@ -276,6 +281,12 @@
                 copyOut(out_p, out, outCount * 4);
             },
             pointer_event: calls.rive_artboard_pointer_event,
+            gamepad_event: (artboard, payload, byteCount) => {
+                stackReset();
+                const payload_p = stageIn(payload, byteCount);
+                const ret = calls.rive_artboard_gamepad_event(artboard, payload_p, byteCount);
+                return ret;
+            },
             animation: (artboard, name, length) => {
                 stackReset();
                 const name_p = stageIn(name, length);
@@ -286,6 +297,7 @@
             animation_duration: calls.rive_artboard_animation_duration,
             animation_advance: calls.rive_artboard_animation_advance,
             animation_set_time: calls.rive_artboard_animation_set_time,
+            add_to_path: calls.rive_artboard_add_to_path,
             node: (artboard, name, length) => {
                 stackReset();
                 const name_p = stageIn(name, length);
@@ -634,6 +646,12 @@
                 const ret = calls.rive_gpu_bind_group_layout_from_shader(shaderModule, groupIndex, dynamicUBOs_p, dynamicUBOCount);
                 return ret;
             },
+            bind_group_layout_from_shaders: (vertexModule, fragmentModule, groupIndex, dynamicUBOs, dynamicUBOCount) => {
+                stackReset();
+                const dynamicUBOs_p = stageIn(dynamicUBOs, dynamicUBOCount * 4);
+                const ret = calls.rive_gpu_bind_group_layout_from_shaders(vertexModule, fragmentModule, groupIndex, dynamicUBOs_p, dynamicUBOCount);
+                return ret;
+            },
             bind_group_new: (layout, ubos, uboByteCount, textures, textureByteCount, samplers, samplerByteCount) => {
                 stackReset();
                 const ubos_p = stageIn(ubos, uboByteCount);
@@ -841,6 +859,21 @@
             radial: unsupported('rive_shader_radial'),
             release: calls.rive_shader_release,
         },
+        rive_test_v1: {
+            blob: (name, nameLength, out, outCount) => {
+                stackReset();
+                const name_p = stageIn(name, nameLength);
+                const out_p = stageIn(out, outCount);
+                const ret = calls.rive_test_blob(name_p, nameLength, out_p, outCount);
+                copyOut(out_p, out, outCount);
+                return ret;
+            },
+        },
+        rive_transition_v1: {
+            child_draw: calls.rive_transition_child_draw,
+            child_width: calls.rive_transition_child_width,
+            child_height: calls.rive_transition_child_height,
+        },
         rive_renderer_v1: {
             save: calls.rive_renderer_save,
             restore: calls.rive_renderer_restore,
diff --git a/tests/common/testing_window.hpp b/tests/common/testing_window.hpp
index e18b122..7aa6c31 100644
--- a/tests/common/testing_window.hpp
+++ b/tests/common/testing_window.hpp
@@ -151,6 +151,16 @@
             immediate,
         };
         PresentMode presentMode = PresentMode::fifo;
+
+        // The display refresh a window asks for where the platform lets it
+        // choose; others keep their default.
+        enum class Refresh
+        {
+            fixed60,
+            fixed120,
+            variable,
+        };
+        Refresh refresh = Refresh::fixed60;
     };
 
     enum class Visibility
diff --git a/tests/gm/gmmain.cpp b/tests/gm/gmmain.cpp
index 6ebd0df..d278e10 100644
--- a/tests/gm/gmmain.cpp
+++ b/tests/gm/gmmain.cpp
@@ -164,6 +164,9 @@
     MAKE_GM(strokedlines)
     MAKE_GM(strokefill)
     MAKE_GM(stroke_position)
+    MAKE_GM(stroke_position_shapes)
+    MAKE_GM(stroke_position_shapes_feathered)
+    MAKE_GM(stroke_position_winding)
     MAKE_GM(stroke_position_clipped)
     MAKE_GM(bug339297)
     MAKE_GM(bug339297_as_clip)
diff --git a/tests/gm/stroke_position_shapes.cpp b/tests/gm/stroke_position_shapes.cpp
new file mode 100644
index 0000000..97ef3f8
--- /dev/null
+++ b/tests/gm/stroke_position_shapes.cpp
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// Inside/center/outside strokes on the shapes the circle-only stroke_position
+// GMs don't reach. Each cell draws the stroke, then the path's fill region in
+// translucent purple, then the path itself as a thin red line.
+//
+// Pinned behavior, all intended:
+//  - Inside/outside close open paths, so the closing segment is stroked too.
+//  - Joins only exist on the outer side of a turn: inside strokes show them
+//    at concave corners, outside strokes at convex ones.
+//  - Which side is "inside" follows the stroke path's own fill rule, so holes
+//    and contours wound in opposite directions resolve as that rule says.
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+#include "rive/renderer.hpp"
+
+using namespace rivegm;
+using namespace rive;
+
+namespace
+{
+constexpr float kCell = 160.0f;
+constexpr float kThickness = 18.0f;
+
+Path openZigzag()
+{
+    return PathBuilder()
+        .moveTo(24, 50)
+        .lineTo(60, 120)
+        .lineTo(96, 50)
+        .lineTo(136, 120)
+        .detach();
+}
+
+// Four convex corners and one concave one, at the notch.
+Path notchedSquare()
+{
+    return PathBuilder()
+        .moveTo(30, 30)
+        .lineTo(130, 30)
+        .lineTo(130, 130)
+        .lineTo(80, 80)
+        .lineTo(30, 130)
+        .close()
+        .detach();
+}
+
+// Both squares wound the same way, so only even-odd sees a hole.
+Path squareWithHole(FillRule fillRule)
+{
+    Path path = PathBuilder()
+                    .moveTo(30, 30)
+                    .lineTo(130, 30)
+                    .lineTo(130, 130)
+                    .lineTo(30, 130)
+                    .close()
+                    .moveTo(60, 60)
+                    .lineTo(100, 60)
+                    .lineTo(100, 100)
+                    .lineTo(60, 100)
+                    .close()
+                    .detach();
+    path->fillRule(fillRule);
+    return path;
+}
+
+// A clockwise square beside a counter-clockwise one, as two pen-drawn paths
+// in one shape can be.
+Path oppositeSquares()
+{
+    return PathBuilder()
+        .moveTo(20, 40)
+        .lineTo(90, 40)
+        .lineTo(90, 110)
+        .lineTo(20, 110)
+        .close()
+        .moveTo(110, 70)
+        .lineTo(110, 120)
+        .lineTo(140, 120)
+        .lineTo(140, 70)
+        .close()
+        .detach();
+}
+
+void drawBackground(Renderer* renderer, float width, float height)
+{
+    Paint bg;
+    bg->color(0xff000000);
+    renderer->drawPath(PathBuilder::Rect({0, 0, width, height}), bg);
+}
+
+// [strokePath] is what gets stroked; [fillPath] only shows the region the
+// stroke is measured against, and is usually the same path.
+void drawCell(Renderer* renderer,
+              const Path& strokePath,
+              const Path& fillPath,
+              StrokeParams params,
+              float feather)
+{
+    Paint stroke;
+    stroke->color(0xFFEFEFEF);
+    stroke->stroke(params);
+    stroke->feather(feather);
+    renderer->drawPath(strokePath, stroke);
+
+    Paint fill;
+    fill->color(0x5f881177);
+    renderer->drawPath(fillPath, fill);
+
+    Paint line;
+    line->color(0xFFFF3030);
+    line->stroke({.thickness = 1.5f});
+    renderer->drawPath(strokePath, line);
+}
+
+// Rows: shape x join. Columns: inside, center, outside.
+void drawShapesByJoin(Renderer* renderer, float feather)
+{
+    drawBackground(renderer, 3 * kCell, 9 * kCell);
+
+    Path shapes[] = {openZigzag(),
+                     notchedSquare(),
+                     squareWithHole(FillRule::evenOdd)};
+    StrokeJoin joins[] = {StrokeJoin::miter,
+                          StrokeJoin::round,
+                          StrokeJoin::bevel};
+    for (int s = 0; s < 3; ++s)
+    {
+        for (int j = 0; j < 3; ++j)
+        {
+            for (int p = 0; p < 3; ++p)
+            {
+                renderer->save();
+                renderer->translate(p * kCell, (s * 3 + j) * kCell);
+                drawCell(renderer,
+                         shapes[s],
+                         shapes[s],
+                         {.thickness = kThickness,
+                          .join = joins[j],
+                          .position = StrokePosition(p)},
+                         feather);
+                renderer->restore();
+            }
+        }
+    }
+}
+} // namespace
+
+DEF_SIMPLE_GM(stroke_position_shapes, 480, 1440, renderer)
+{
+    drawShapesByJoin(renderer, 0.0f);
+}
+
+// Feathered inside/outside strokes take a different branch in the renderer:
+// clip by the path, then draw the thickened stroke.
+DEF_SIMPLE_GM(stroke_position_shapes_feathered, 480, 1440, renderer)
+{
+    drawShapesByJoin(renderer, 6.0f);
+}
+
+// What the editor sends: stroke paths are always non-zero, whatever the Fill's
+// rule. Row 1 is a same-direction hole that an even-odd Fill shows, but the
+// non-zero stroke path counts as inside. Row 2 is two opposite-wound squares.
+// Columns: inside, center, outside.
+DEF_SIMPLE_GM(stroke_position_winding, 480, 320, renderer)
+{
+    drawBackground(renderer, 3 * kCell, 2 * kCell);
+
+    Path hole = squareWithHole(FillRule::nonZero);
+    Path holeFill = squareWithHole(FillRule::evenOdd);
+    Path pair = oppositeSquares();
+    for (int p = 0; p < 3; ++p)
+    {
+        StrokeParams params = {.thickness = 12.0f,
+                               .position = StrokePosition(p)};
+        renderer->save();
+        renderer->translate(p * kCell, 0);
+        drawCell(renderer, hole, holeFill, params, 0.0f);
+        renderer->translate(0, kCell);
+        drawCell(renderer, pair, pair, params, 0.0f);
+        renderer->restore();
+    }
+}
diff --git a/tests/player/player.cpp b/tests/player/player.cpp
index 80df1aa..299dc39 100644
--- a/tests/player/player.cpp
+++ b/tests/player/player.cpp
@@ -282,6 +282,7 @@
     bool onlyUbershaders = false;
     // Applied after parsing, since --backend resets the backend params.
     std::optional<TestingWindow::BackendParams::PresentMode> presentMode;
+    std::optional<TestingWindow::BackendParams::Refresh> refresh;
 
     for (int i = 1; i < argc; ++i)
     {
@@ -334,6 +335,30 @@
                     : strcmp(fit, "fill") == 0  ? rive::Fit::fill
                                                 : rive::Fit::contain;
         }
+        else if (strcmp(argv[i], "--refresh") == 0 && i + 1 < argc)
+        {
+            using RR = TestingWindow::BackendParams::Refresh;
+            const char* rate = argv[++i];
+            if (strcmp(rate, "60") == 0)
+            {
+                refresh = RR::fixed60;
+            }
+            else if (strcmp(rate, "120") == 0)
+            {
+                refresh = RR::fixed120;
+            }
+            else if (strcmp(rate, "vrr") == 0)
+            {
+                refresh = RR::variable;
+            }
+            else
+            {
+                fprintf(stderr,
+                        "--refresh must be 60, 120 or vrr, not %s\n",
+                        rate);
+                abort();
+            }
+        }
         else if (strcmp(argv[i], "--present") == 0 && i + 1 < argc)
         {
             using PM = TestingWindow::BackendParams::PresentMode;
@@ -406,6 +431,10 @@
     {
         options.backendParams.presentMode = *presentMode;
     }
+    if (refresh)
+    {
+        options.backendParams.refresh = *refresh;
+    }
     if (onlyUbershaders)
     {
         options.backendParams.shaderCompilationMode =
diff --git a/tests/unit_tests/assets/NotoColorEmojiCOLRv1.subset.ttf b/tests/unit_tests/assets/NotoColorEmojiCOLRv1.subset.ttf
new file mode 100644
index 0000000..8fce7a0
--- /dev/null
+++ b/tests/unit_tests/assets/NotoColorEmojiCOLRv1.subset.ttf
Binary files differ
diff --git a/tests/unit_tests/assets/fonts/two_faces.ttc b/tests/unit_tests/assets/fonts/two_faces.ttc
deleted file mode 100644
index fe8a08b..0000000
--- a/tests/unit_tests/assets/fonts/two_faces.ttc
+++ /dev/null
Binary files differ
diff --git a/tests/unit_tests/runtime/color_glyph_test.cpp b/tests/unit_tests/runtime/color_glyph_test.cpp
index bf9f56b..5a4fad5 100644
--- a/tests/unit_tests/runtime/color_glyph_test.cpp
+++ b/tests/unit_tests/runtime/color_glyph_test.cpp
@@ -487,3 +487,115 @@
     NoOpRenderer renderer;
     rawText.render(&renderer);
 }
+
+// U+1F389, U+1F600 and U+1F605 are glyphs 1 to 3, subset from Android's COLRv1
+// Noto.
+static const char* colrV1Font = "assets/NotoColorEmojiCOLRv1.subset.ttf";
+
+TEST_CASE("COLRv1 radial gradient maps to glyph space", "[color_glyph]")
+{
+    auto font = loadFont(colrV1Font);
+    REQUIRE(font != nullptr);
+
+    std::vector<Font::ColorGlyphLayer> layers;
+    REQUIRE(font->getColorLayers(2, layers) == 10);
+
+    // The face is a radial gradient centered at (630, 360) of a 1024 upem.
+    const auto& face = layers[0];
+    REQUIRE(face.paintType == Font::ColorGlyphPaintType::radialGradient);
+    REQUIRE(face.stops.size() == 3);
+    CHECK(face.stops[0].color == 0xFFFDE030);
+    CHECK(face.x1 == Approx(630.0f / 1024.0f));
+    CHECK(face.y1 == Approx(-360.0f / 1024.0f));
+    CHECK(face.r1 == Approx(534.0f / 1024.0f));
+    AABB faceBounds = face.path.bounds();
+    CHECK(faceBounds.contains(Vec2D(face.x1, face.y1)));
+}
+
+TEST_CASE("COLRv1 transformed clip glyphs keep their transform",
+          "[color_glyph]")
+{
+    auto font = loadFont(colrV1Font);
+    REQUIRE(font != nullptr);
+
+    std::vector<Font::ColorGlyphLayer> layers;
+    REQUIRE(font->getColorLayers(2, layers) == 10);
+
+    // Both eye highlights reuse one glyph, translated by -41.906 and 317.625.
+    AABB left = layers[2].path.bounds();
+    AABB right = layers[4].path.bounds();
+    CHECK(right.minX - left.minX == Approx((317.625f + 41.906f) / 1024.0f));
+    CHECK(right.minY == Approx(left.minY));
+}
+
+TEST_CASE("COLRv1 radial gradients keep their ellipse", "[color_glyph]")
+{
+    auto font = loadFont(colrV1Font);
+    REQUIRE(font != nullptr);
+
+    // The sweat drop on U+1F605 is a radial gradient under an uneven scale.
+    std::vector<Font::ColorGlyphLayer> layers;
+    REQUIRE(font->getColorLayers(3, layers) > 0);
+    bool sawEllipse = false;
+    for (const auto& layer : layers)
+    {
+        if (layer.paintType != Font::ColorGlyphPaintType::radialGradient)
+        {
+            continue;
+        }
+        const Mat2D& m = layer.radialTransform;
+        Vec2D center = m * Vec2D(0, 0);
+        CHECK(center.x == Approx(layer.x1));
+        CHECK(center.y == Approx(layer.y1));
+        float sx = Vec2D(m[0], m[1]).length();
+        float sy = Vec2D(m[2], m[3]).length();
+        sawEllipse |= std::abs(sx - sy) > 0.2f * std::max(sx, sy);
+    }
+    CHECK(sawEllipse);
+}
+
+class GradientCountingFactory : public NoOpFactory
+{
+public:
+    int linear = 0;
+    int radial = 0;
+
+    rcp<RenderShader> makeLinearGradient(float,
+                                         float,
+                                         float,
+                                         float,
+                                         const ColorInt[],
+                                         const float[],
+                                         size_t) override
+    {
+        linear++;
+        return nullptr;
+    }
+
+    rcp<RenderShader> makeRadialGradient(float,
+                                         float,
+                                         float,
+                                         const ColorInt[],
+                                         const float[],
+                                         size_t) override
+    {
+        radial++;
+        return nullptr;
+    }
+};
+
+TEST_CASE("RawText draws COLRv1 gradient layers with gradient shaders",
+          "[color_glyph]")
+{
+    auto font = loadFont(colrV1Font);
+    REQUIRE(font != nullptr);
+
+    GradientCountingFactory factory;
+    RawText rawText(&factory);
+    rawText.append("\xF0\x9F\x8E\x89\xF0\x9F\x98\x80", nullptr, font, 32.0f);
+
+    NoOpRenderer renderer;
+    rawText.render(&renderer);
+    CHECK(factory.linear == 1);
+    CHECK(factory.radial == 1);
+}
diff --git a/tests/unit_tests/runtime/color_test.cpp b/tests/unit_tests/runtime/color_test.cpp
index 3105ec8..2ba829d 100644
--- a/tests/unit_tests/runtime/color_test.cpp
+++ b/tests/unit_tests/runtime/color_test.cpp
@@ -1,3 +1,4 @@
+#include "rive/data_bind/data_values/data_value_color.hpp"
 #include "rive/shapes/paint/color.hpp"
 
 #include <catch.hpp>
@@ -41,3 +42,14 @@
     CHECK(rive::colorBlue(colorLerped) == 0);
     CHECK(rive::colorAlpha(colorLerped) == 0);
 }
+
+TEST_CASE("data value color channels keep their low byte", "[color]")
+{
+    rive::DataValueColor color(0x80102030);
+    color.red(-1);
+    CHECK((uint32_t)color.value() == 0x80FF2030);
+    color.blue(300);
+    CHECK((uint32_t)color.value() == 0x80FF202C);
+    color.alpha(0x1FF);
+    CHECK((uint32_t)color.value() == 0xFFFF202C);
+}
diff --git a/tests/unit_tests/runtime/focus_test.cpp b/tests/unit_tests/runtime/focus_test.cpp
index a3508ea..5f9b907 100644
--- a/tests/unit_tests/runtime/focus_test.cpp
+++ b/tests/unit_tests/runtime/focus_test.cpp
@@ -5018,3 +5018,41 @@
 
     CHECK(silver.matches("gamepad_inputs_test-collapsing"));
 }
+
+TEST_CASE("Destroying an artboard on an adopted manager with focus held in a "
+          "nested artboard",
+          "[FocusManager]")
+{
+    // Editor shape: Dart owns the manager, so ~Artboard skips
+    // cleanupFocusTree and focus is still live when the m_Objects loop runs.
+    // Blur callbacks fired from that loop must not reach deleted components.
+    rive::FocusManager external;
+    auto file = ReadRiveFile("assets/swappable_artboards_focus.riv");
+    auto artboard = file->artboardNamed("Main");
+    REQUIRE(artboard != nullptr);
+    artboard->adoptFocusManager(&external);
+
+    auto stateMachine = artboard->stateMachineAt(0);
+    REQUIRE(stateMachine != nullptr);
+    REQUIRE(stateMachine->focusManager() == &external);
+
+    auto vmi = file->createDefaultViewModelInstance(artboard.get());
+    REQUIRE(vmi != nullptr);
+    stateMachine->bindViewModelInstance(vmi);
+    stateMachine->advanceAndApply(0.016f);
+    stateMachine->advanceAndApply(0.016f);
+
+    CHECK(stateMachine->focusNext() == true);
+    CHECK(stateMachine->focusNext() == true);
+    CHECK(stateMachine->focusNext() == true);
+    auto* focusedArtboard = external.primaryFocusImmediateArtboard();
+    REQUIRE(focusedArtboard != nullptr);
+    REQUIRE(focusedArtboard->name() == "StaticNestWithFocusable");
+
+    // Same order as WrappedStateMachine: machine first, then artboard.
+    stateMachine = nullptr;
+    artboard = nullptr;
+
+    CHECK(external.primaryFocus() == nullptr);
+    CHECK(external.hasFocusableContent() == false);
+}
diff --git a/tests/unit_tests/runtime/font_test.cpp b/tests/unit_tests/runtime/font_test.cpp
index 034c3ba..1823f3a 100644
--- a/tests/unit_tests/runtime/font_test.cpp
+++ b/tests/unit_tests/runtime/font_test.cpp
@@ -24,7 +24,7 @@
     return {std::move(font), size, -1.0f, 0.0f, n, 0};
 }
 
-static std::vector<uint8_t> readBytes(const char* filename)
+static rcp<Font> loadFont(const char* filename)
 {
     FILE* fp = fopen(filename, "rb");
     REQUIRE(fp != nullptr);
@@ -35,12 +35,7 @@
     std::vector<uint8_t> bytes(length);
     REQUIRE(fread(bytes.data(), 1, length, fp) == length);
     fclose(fp);
-    return bytes;
-}
 
-static rcp<Font> loadFont(const char* filename)
-{
-    auto bytes = readBytes(filename);
     return HBFont::Decode(bytes);
 }
 
@@ -305,93 +300,6 @@
 #endif
 }
 
-TEST_CASE("mapped font opens the requested collection face", "[text]")
-{
-    const char* path = "assets/fonts/two_faces.ttc";
-#ifndef RIVE_HB_FILE_MAPPING
-    REQUIRE(HBFont::DecodeFile(path, 1) == nullptr);
-    return;
-#else
-    // Face 0 covers a-f at weight 400, face 1 covers only a at weight 250.
-    auto first = HBFont::DecodeFile(path, 0);
-    auto second = HBFont::DecodeFile(path, 1);
-    REQUIRE(first != nullptr);
-    REQUIRE(second != nullptr);
-    REQUIRE(first->hasGlyph('b'));
-    REQUIRE(second->hasGlyph('a'));
-    REQUIRE_FALSE(second->hasGlyph('b'));
-    REQUIRE(first->getWeight() != second->getWeight());
-    REQUIRE(HBFont::DecodeFile(path, 2) == nullptr);
-#endif
-}
-
-TEST_CASE("mapped coverage probes respect collection faces and promote safely",
-          "[text]")
-{
-    const char* path = "assets/fonts/two_faces.ttc";
-#ifndef RIVE_HB_FILE_MAPPING
-    REQUIRE(HBFont::ProbeFile(path) == nullptr);
-#else
-    auto first = HBFont::ProbeFile(path, 0);
-    auto second = HBFont::ProbeFile(path, 1);
-    REQUIRE(first != nullptr);
-    REQUIRE(second != nullptr);
-    REQUIRE(first->hasGlyph('b'));
-    REQUIRE(second->hasGlyph('a'));
-    REQUIRE_FALSE(second->hasGlyph('b'));
-    REQUIRE_FALSE(first->hasGlyph(0x10FFFF));
-    REQUIRE(HBFont::ProbeFile(path, 2) == nullptr);
-
-    auto font = first->makeFont();
-    REQUIRE(font != nullptr);
-    REQUIRE_FALSE(first->hasGlyph('b'));
-    REQUIRE(first->makeFont() == nullptr);
-    first.reset();
-    second.reset();
-    REQUIRE(font->hasGlyph('b'));
-    REQUIRE(font->getWeight() == 400);
-
-    // Shaping and drawing must still work after the probes release their
-    // references.
-    std::vector<rive::Unichar> text;
-    std::vector<rive::TextRun> runs;
-    runs.push_back(append(&text, font, 32.0f, "b"));
-    auto shape = font->shapeText(text, runs);
-    REQUIRE(shape.size() == 1);
-    REQUIRE(shape[0].runs[0].glyphs.size() == 1);
-    REQUIRE(font->getPath(shape[0].runs[0].glyphs[0]).verbs().size() > 0);
-#endif
-}
-
-TEST_CASE("owned font bytes are adopted rather than copied", "[text]")
-{
-    rcp<Font> font;
-    const uint8_t* data;
-    {
-        auto bytes = readBytes("assets/fonts/Inter_18pt-Regular.ttf");
-        data = bytes.data();
-        font = HBFont::Decode(std::move(bytes));
-    }
-    REQUIRE(font != nullptr);
-
-    hb_blob_t* blob = hb_face_reference_blob(
-        hb_font_get_face(static_cast<HBFont*>(font.get())->font()));
-    REQUIRE(reinterpret_cast<const uint8_t*>(hb_blob_get_data(blob, nullptr)) ==
-            data);
-    hb_blob_destroy(blob);
-
-    // Shaping reads the adopted bytes after the caller's scope is gone.
-    std::vector<rive::Unichar> text;
-    std::vector<rive::TextRun> runs;
-    runs.push_back(append(&text, font, 32.0f, "A"));
-    auto shape = font->shapeText(text, runs);
-    REQUIRE(shape.size() == 1);
-    REQUIRE(shape[0].runs[0].glyphs.size() == 1);
-    REQUIRE(font->getPath(shape[0].runs[0].glyphs[0]).verbs().size() > 0);
-
-    REQUIRE(HBFont::Decode(std::vector<uint8_t>()) == nullptr);
-}
-
 TEST_CASE("DecodeFile returns null rather than failing hard", "[text]")
 {
     // Callers treat null as "use Decode instead", so every failure this call
@@ -401,8 +309,6 @@
     // blob is a font -- the byte-based Decode has the same behaviour.
     REQUIRE(HBFont::DecodeFile(nullptr) == nullptr);
     REQUIRE(HBFont::DecodeFile("assets/does_not_exist.ttf") == nullptr);
-    REQUIRE(HBFont::ProbeFile(nullptr) == nullptr);
-    REQUIRE(HBFont::ProbeFile("assets/does_not_exist.ttf") == nullptr);
 
     // An empty file: rejected on size before anything is mapped.
     const char* emptyPath = "assets/empty_font_test_file.tmp";
@@ -410,6 +316,5 @@
     REQUIRE(fp != nullptr);
     fclose(fp);
     REQUIRE(HBFont::DecodeFile(emptyPath) == nullptr);
-    REQUIRE(HBFont::ProbeFile(emptyPath) == nullptr);
     remove(emptyPath);
 }
diff --git a/tests/unit_tests/runtime/paint_outset_test.cpp b/tests/unit_tests/runtime/paint_outset_test.cpp
index 4204059..15e07df 100644
--- a/tests/unit_tests/runtime/paint_outset_test.cpp
+++ b/tests/unit_tests/runtime/paint_outset_test.cpp
@@ -12,8 +12,12 @@
  * preview will crop where playback does not.
  */
 
+#include <rive/artboard.hpp>
+#include <rive/file.hpp>
 #include <rive/shapes/paint/paint_outset.hpp>
+#include <rive/shapes/paint/stroke.hpp>
 
+#include "rive_file_reader.hpp"
 #include <catch.hpp>
 
 using namespace rive;
@@ -72,6 +76,31 @@
     CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f * kMiterLimit));
 }
 
+TEST_CASE("an outside stroke reaches its full thickness", "[paint-outset]")
+{
+    // Drawn as a centered stroke twice as thick, clipped to the outside.
+    StrokeParams s;
+    s.thickness = 10.0f;
+    s.join = StrokeJoin::round;
+    s.position = StrokePosition::outside;
+    CHECK(paintBoundsOutset(s, 0.0f) == Approx(10.0f));
+
+    s.join = StrokeJoin::miter;
+    CHECK(paintBoundsOutset(s, 0.0f) == Approx(10.0f * kMiterLimit));
+    CHECK(paintBoundsOutset(s, 0.0f) == Approx(40.0f));
+
+    s.join = StrokeJoin::bevel;
+    s.cap = StrokeCap::square;
+    CHECK(paintBoundsOutset(s, 0.0f) == Approx(10.0f * math::SQRT2));
+
+    // Clipped to the path, an inside stroke reaches nothing past it. Half its
+    // thickness, like a centered one, is kept as a safe over-estimate.
+    s.position = StrokePosition::inside;
+    s.join = StrokeJoin::round;
+    s.cap = StrokeCap::butt;
+    CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f));
+}
+
 TEST_CASE("a feather reaches 1.5x its authored strength", "[paint-outset]")
 {
     // Design tools quote a blur as the width of two standard deviations, so an
@@ -125,3 +154,29 @@
         }
     }
 }
+
+TEST_CASE("shapePaintOutset reads a stroke's position", "[paint-outset]")
+{
+    auto file = ReadRiveFile("assets/stroke_name_test.riv");
+    auto artboard = file->artboardDefault();
+    REQUIRE(artboard != nullptr);
+    artboard->advance(0.0f);
+    auto stroke = artboard->find<Stroke>("white_stroke");
+    REQUIRE(stroke != nullptr);
+    REQUIRE(stroke->shouldDraw());
+
+    StrokeParams params;
+    params.thickness = stroke->thickness();
+    params.join = static_cast<StrokeJoin>(stroke->join());
+    params.cap = static_cast<StrokeCap>(stroke->cap());
+    const float centeredStroke = paintBoundsOutset(params, 0.0f);
+    params.position = StrokePosition::outside;
+    const float outsideStroke = paintBoundsOutset(params, 0.0f);
+    REQUIRE(outsideStroke > centeredStroke);
+
+    // Compared as a difference so any feather on the stroke cancels out.
+    const float centered = shapePaintOutset(stroke).outset;
+    stroke->position((uint8_t)StrokePosition::outside);
+    CHECK(shapePaintOutset(stroke).outset - centered ==
+          Approx(outsideStroke - centeredStroke));
+}
diff --git a/tests/unit_tests/runtime/scripting/scripting_context_test.cpp b/tests/unit_tests/runtime/scripting/scripting_context_test.cpp
index 9e70caf..78dd2bd 100644
--- a/tests/unit_tests/runtime/scripting/scripting_context_test.cpp
+++ b/tests/unit_tests/runtime/scripting/scripting_context_test.cpp
@@ -1993,3 +1993,12 @@
 
     object.scriptDispose();
 }
+
+// print joins its arguments with a tab, as Luau's own print does, so several
+// values on one line read back apart rather than run together.
+TEST_CASE("print separates its arguments with tabs", "[scripting]")
+{
+    ScriptingTest vm(R"(print("alpha", 1, true, nil, 2.5))", 0);
+    REQUIRE(vm.console.size() == 1);
+    CHECK(vm.console[0] == "alpha\t1\ttrue\tnil\t2.5");
+}
diff --git a/tests/unit_tests/runtime/serialized_replay_test.cpp b/tests/unit_tests/runtime/serialized_replay_test.cpp
index 139b216..98e2c17 100644
--- a/tests/unit_tests/runtime/serialized_replay_test.cpp
+++ b/tests/unit_tests/runtime/serialized_replay_test.cpp
@@ -16,6 +16,7 @@
 
 #include <catch.hpp>
 #include <cstring>
+#include <vector>
 
 using namespace rive;
 
@@ -37,6 +38,7 @@
     paint->blendMode(BlendMode::multiply);
     paint->feather(2.0f);
     paint->additiveness(0.375f);
+    paint->strokePosition(StrokePosition::outside);
 
     RawPath rp;
     rp.move({0, 0});
@@ -297,6 +299,69 @@
     CHECK(sink.got[0].uvScale.y == 1.0f);
 }
 
+TEST_CASE("a centered stroke records no stroke position op",
+          "[serialize][replay]")
+{
+    // Every stroke in an existing file is centered, so recording one has to
+    // leave the stream byte for byte what it was before the op existed, or
+    // every silver with a stroke in it would need regenerating.
+    SerializingFactory plain;
+    auto plainPaint = plain.makeRenderPaint();
+    plainPaint->style(RenderPaintStyle::stroke);
+
+    SerializingFactory centered;
+    auto centeredPaint = centered.makeRenderPaint();
+    centeredPaint->style(RenderPaintStyle::stroke);
+    centeredPaint->strokePosition(StrokePosition::center);
+
+    auto sa = plain.bytes();
+    auto sb = centered.bytes();
+    REQUIRE(sa.size() == sb.size());
+    CHECK(std::memcmp(sa.data(), sb.data(), sa.size()) == 0);
+}
+
+TEST_CASE("a replayed paint keeps its stroke position", "[serialize][replay]")
+{
+    SerializingFactory a;
+    auto paint = a.makeRenderPaint();
+    paint->style(RenderPaintStyle::stroke);
+    paint->strokePosition(StrokePosition::inside);
+    auto recorded = a.bytes().size();
+    // Setting the same value again is deduped like every other paint op.
+    paint->strokePosition(StrokePosition::inside);
+    CHECK(a.bytes().size() == recorded);
+    paint->strokePosition(StrokePosition::outside);
+    CHECK(a.bytes().size() > recorded);
+
+    SerializingFactory b;
+    auto r = b.makeRenderer();
+    REQUIRE(replaySerializedCommands(a.bytes(), &b, r.get()));
+    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("an unknown stroke position fails replay", "[serialize][replay]")
+{
+    // Renderers assert on a position they don't know, so replay has to stop
+    // at it rather than pass it through.
+    SerializingFactory a;
+    auto paint = a.makeRenderPaint();
+    paint->strokePosition(StrokePosition::outside);
+    std::vector<uint8_t> stream(a.bytes().begin(), a.bytes().end());
+    // The op ends the stream: [strokePosition][paint id][value].
+    REQUIRE(stream.back() == (uint8_t)StrokePosition::outside);
+    stream.back() = 7;
+
+    SerializingFactory b;
+    auto r = b.makeRenderer();
+    CHECK_FALSE(replaySerializedCommands(
+        Span<const uint8_t>(stream.data(), stream.size()),
+        &b,
+        r.get()));
+}
+
 TEST_CASE("serialized replay rejects a bad header", "[serialize][replay]")
 {
     const uint8_t garbage[8] = {'X', 'X', 'X', 'X', 1, 0, 0, 0};
diff --git a/tests/unit_tests/runtime/stroke_test.cpp b/tests/unit_tests/runtime/stroke_test.cpp
index 23eddd1..712c11f 100644
--- a/tests/unit_tests/runtime/stroke_test.cpp
+++ b/tests/unit_tests/runtime/stroke_test.cpp
@@ -1,10 +1,13 @@
+#include <rive/animation/keyframe_uint.hpp>
 #include <rive/file.hpp>
+#include <rive/generated/core_registry.hpp>
 #include <rive/node.hpp>
 #include <rive/shapes/rectangle.hpp>
 #include <rive/shapes/shape.hpp>
 #include <rive/shapes/paint/stroke.hpp>
 #include <rive/shapes/paint/solid_color.hpp>
 #include <rive/shapes/paint/color.hpp>
+#include <utils/no_op_factory.hpp>
 #include <utils/no_op_renderer.hpp>
 #include "rive_file_reader.hpp"
 #include <catch.hpp>
@@ -21,3 +24,141 @@
     stroke->paint()->as<rive::SolidColor>()->colorValue(
         rive::colorARGB(255, 0, 255, 255));
 }
+
+namespace
+{
+// ReadRiveFile builds paints from a NoOpFactory, which discards everything.
+// This records the stroke position Stroke pushes onto its RenderPaint.
+class StrokePositionPaint : public rive::RenderPaint
+{
+public:
+    rive::StrokePosition position = rive::StrokePosition::center;
+    int positionWrites = 0;
+
+    void style(rive::RenderPaintStyle) override {}
+    void color(rive::ColorInt) override {}
+    void thickness(float) override {}
+    void join(rive::StrokeJoin) override {}
+    void cap(rive::StrokeCap) override {}
+    void strokePosition(rive::StrokePosition value) override
+    {
+        position = value;
+        positionWrites++;
+    }
+    void blendMode(rive::BlendMode) override {}
+    void shader(rive::rcp<rive::RenderShader>) override {}
+    void invalidateStroke() override {}
+};
+
+class StrokePositionFactory : public rive::NoOpFactory
+{
+public:
+    rive::rcp<rive::RenderPaint> makeRenderPaint() override
+    {
+        return rive::make_rcp<StrokePositionPaint>();
+    }
+};
+} // namespace
+
+TEST_CASE("stroke position defaults to center", "[stroke]")
+{
+    StrokePositionFactory factory;
+    auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory);
+    auto artboard = file->artboard()->instance();
+    auto stroke = artboard->find<rive::Stroke>("white_stroke");
+    REQUIRE(stroke != nullptr);
+
+    // Files written before the property existed never carry it.
+    CHECK(stroke->position() == (uint8_t)rive::StrokePosition::center);
+    CHECK(stroke->strokePosition() == rive::StrokePosition::center);
+    auto* paint = static_cast<StrokePositionPaint*>(stroke->renderPaint());
+    REQUIRE(paint != nullptr);
+    CHECK(paint->positionWrites > 0);
+    CHECK(paint->position == rive::StrokePosition::center);
+}
+
+TEST_CASE("changing stroke position reaches the render paint", "[stroke]")
+{
+    StrokePositionFactory factory;
+    auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory);
+    auto artboard = file->artboard()->instance();
+    auto stroke = artboard->find<rive::Stroke>("white_stroke");
+    REQUIRE(stroke != nullptr);
+    auto* paint = static_cast<StrokePositionPaint*>(stroke->renderPaint());
+    REQUIRE(paint != nullptr);
+
+    stroke->position((uint8_t)rive::StrokePosition::inside);
+    artboard->advance(0.0f);
+    CHECK(paint->position == rive::StrokePosition::inside);
+
+    stroke->position((uint8_t)rive::StrokePosition::outside);
+    artboard->advance(0.0f);
+    CHECK(paint->position == rive::StrokePosition::outside);
+
+    // A value from a newer file this runtime doesn't know draws centered.
+    stroke->position(7);
+    artboard->advance(0.0f);
+    CHECK(stroke->strokePosition() == rive::StrokePosition::center);
+    CHECK(paint->position == rive::StrokePosition::center);
+}
+
+TEST_CASE("stroke applyTo carries its position", "[stroke]")
+{
+    StrokePositionFactory factory;
+    auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory);
+    auto artboard = file->artboard()->instance();
+    auto stroke = artboard->find<rive::Stroke>("white_stroke");
+    REQUIRE(stroke != nullptr);
+
+    // Text and layout paints get their stroke params through applyTo.
+    stroke->position((uint8_t)rive::StrokePosition::outside);
+    StrokePositionPaint target;
+    stroke->applyTo(&target, 1.0f);
+    CHECK(target.position == rive::StrokePosition::outside);
+}
+
+TEST_CASE("keyframes and binds drive stroke position", "[stroke]")
+{
+    // KeyFrameUint and the enum/number data bind contexts all land in
+    // CoreRegistry::setUint, so both reach the paint through positionChanged.
+    StrokePositionFactory factory;
+    auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory);
+    auto artboard = file->artboard()->instance();
+    auto stroke = artboard->find<rive::Stroke>("white_stroke");
+    REQUIRE(stroke != nullptr);
+    auto* paint = static_cast<StrokePositionPaint*>(stroke->renderPaint());
+    REQUIRE(paint != nullptr);
+
+    rive::KeyFrameUint keyframe;
+    keyframe.value((uint32_t)rive::StrokePosition::outside);
+    keyframe.apply(stroke, rive::StrokeBase::positionPropertyKey, 1.0f);
+    artboard->advance(0.0f);
+    CHECK(paint->position == rive::StrokePosition::outside);
+
+    rive::CoreRegistry::setUint(stroke,
+                                rive::StrokeBase::positionPropertyKey,
+                                (uint32_t)rive::StrokePosition::inside);
+    artboard->advance(0.0f);
+    CHECK(paint->position == rive::StrokePosition::inside);
+}
+
+TEST_CASE("stroke position holds between keyframes", "[stroke]")
+{
+    // An enum has nothing between its values, so a partial mix (a blend
+    // state, or a transition) must switch rather than land on a neighbor.
+    CHECK_FALSE(rive::CoreRegistry::isInterpolatableUint(
+        rive::StrokeBase::positionPropertyKey));
+
+    StrokePositionFactory factory;
+    auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory);
+    auto artboard = file->artboard()->instance();
+    auto stroke = artboard->find<rive::Stroke>("white_stroke");
+    REQUIRE(stroke != nullptr);
+
+    rive::KeyFrameUint keyframe;
+    keyframe.value((uint32_t)rive::StrokePosition::inside);
+    // Center (1) mixed halfway toward inside (0) would round to either
+    // neighbor if it interpolated.
+    keyframe.apply(stroke, rive::StrokeBase::positionPropertyKey, 0.5f);
+    CHECK(stroke->position() == (uint8_t)rive::StrokePosition::inside);
+}
diff --git a/utils/serialized_replay.cpp b/utils/serialized_replay.cpp
index b77ca25..01966fe 100644
--- a/utils/serialized_replay.cpp
+++ b/utils/serialized_replay.cpp
@@ -198,6 +198,18 @@
                 paint->additiveness(reader.readFloat32());
                 break;
             }
+            case SerializeOp::strokePosition:
+            {
+                uint64_t id = reader.readVarUint64();
+                uint64_t position = reader.readVarUint64();
+                RenderPaint* paint = find(paints, id);
+                // Renderers assert on a position they don't know.
+                if (paint == nullptr ||
+                    position > (uint64_t)StrokePosition::outside)
+                    return false;
+                paint->strokePosition(static_cast<StrokePosition>(position));
+                break;
+            }
             case SerializeOp::shader:
             {
                 uint64_t id = reader.readVarUint64();
diff --git a/utils/serializing_factory.cpp b/utils/serializing_factory.cpp
index 1c53baa..8f797fd 100644
--- a/utils/serializing_factory.cpp
+++ b/utils/serializing_factory.cpp
@@ -104,6 +104,8 @@
             return "blendMode";
         case SerializeOp::additiveness:
             return "additiveness";
+        case SerializeOp::strokePosition:
+            return "strokePosition";
         case SerializeOp::shader:
             return "shader";
         case SerializeOp::paintModulatedImage:
@@ -229,6 +231,18 @@
         m_writer->writeVarUint((uint32_t)m_cap);
     }
 
+    void strokePosition(StrokePosition value) override
+    {
+        if (m_strokePosition == value)
+        {
+            return;
+        }
+        m_strokePosition = value;
+        m_writer->writeVarUint((uint32_t)SerializeOp::strokePosition);
+        m_writer->writeVarUint(m_id);
+        m_writer->writeVarUint((uint32_t)m_strokePosition);
+    }
+
     void blendMode(BlendMode value) override
     {
         if (m_blendMode == value)
@@ -312,6 +326,7 @@
     float m_thickness = 1;
     StrokeJoin m_join = StrokeJoin::miter;
     StrokeCap m_cap = StrokeCap::butt;
+    StrokePosition m_strokePosition = StrokePosition::center;
     float m_feather = 0;
     float m_additiveness = 0;
     BlendMode m_blendMode = BlendMode::srcOver;
@@ -1666,6 +1681,22 @@
                     return false;
                 }
                 break;
+            case SerializeOp::strokePosition:
+                if (!varUintMatches(opA,
+                                    "strokeposition_paint_id",
+                                    readerA,
+                                    readerB))
+                {
+                    return false;
+                }
+                if (!varUintMatches(opA,
+                                    "strokeposition_value",
+                                    readerA,
+                                    readerB))
+                {
+                    return false;
+                }
+                break;
             case SerializeOp::feather:
                 if (!varUintMatches(opA, "feather_paint_id", readerA, readerB))
                 {