feat(rasc): document the AnimaScript std and close its gaps with the Luau API (#14524) b80a7fc662
* fix(rasc): one static closure per plain function so references compare equal, and reject a second operator per symbol

* feat(runtime): wasm host ops for transitions, path effect nodes, pointer stamps, artboard paths and gamepads, and test blobs

* feat(rasc): document std/rive with /// and close its gaps with the Luau API

* fix(scripting): Luau docs and declarations match the runtime, translator color setters and path effect nodes

* feat(scripting_workspace): /// docs in AnimaScript hovers and the API docs generator

* docs(cli): regenerate the Luau API bundle from the corrected builtins

* fix(runtime): bound gamepad payload counts one at a time so they cannot wrap

* docs(rasc): fence AnimaScript examples as as, with the grammar registered under that alias

* docs(cli): animascript protocols guide and generated std api reference

* feat(scripting_workspace): translate Transition, Mat4 and every string.format specifier to AnimaScript

* docs(cli): regenerate the AnimaScript API bundle with as fences

* feat(lsp): completion items carry kind, signature and docs for Luau and AnimaScript

* fix(rasc): print floats with schubfach shortest digits and drop formatNumber's exact search

* feat(rasc): infer type arguments from a callback's return and parameter types

* docs(cli): regenerate the AnimaScript API bundle for the promise docs

* fix(rasc): class errors reach the real sink from peeks and callback annotations, duplicate operators report once in source spelling

* fix(rasc): a callback's own type never binds a type parameter positionally, and the hint names parameter annotations when only they would bind it

* refactor(rasc): static closures share one empty closure class

* perf(rasc): call umul128 instead of inlining it eight times into schubfach

* fix(wasm): scope path effect nodes to their update and wire the shim's artboard ops

* refactor(rasc): drop Pattern.match and captures, gsub copies text in runs

* fix(rasc): unwatched properties leave their owner so listen cycles stop growing

* fix(rasc): saturate buffer and date integer conversions like Luau, trim buffer allocations

* fix(rasc): keep host composed geometry when a path is released

* perf(rasc): skip bounds checks on fixed Mat4 entries

* fix(rasc): raise Luau's GPU constructor argument errors instead of a silent null handle

* fix(rasc): match Luau's Vector lerp and normalized bit for bit

* test(cli): GPUBuffer data length guard on both lanes

* refactor(cli): share the api bundle writer between the Luau and AnimaScript generators

* fix(cli): keep operator[]= and generic defaults in the AnimaScript api bundle

* docs(cli): note AnimaScript gamepad indices are 0 based

* ci: test the AnimaScript bundle generator and run rive-cli checks on std changes

* fix(scripting): document a Luau completion or hover only from the --- block directly above it

* perf(scripting): fill Luau completion details only for the entries the filter keeps

* fix(lsp): badge types as structs, enum values as enum members and overloads as methods

* fix(scripting): keep a one line enum's values from inheriting the enum's /// comment

* docs(scripting): AnimaScript API overview, module Functions pages, member anchors and no unsafe in signatures

* ci: check the AnimaScript bundle publishes what the site reference does

* docs(cli): regenerate both api bundles after the review fixes

* refactor(rasc): spell our std and fixture nullable types T?, with ?. and ?? where clearer

* fix(scripting_workspace): emit and document nullable types as T?

* test(lsp): completion after ?. carries the member's docs

* docs(cli): regenerate the AnimaScript api bundle for T?

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index 9a22341..35d7f61 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-1462fc31e1a1ce6a9e649ac0f812222157c8551c
+b80a7fc662e92e5cb8f6daa80a6427d08f1d7b06
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/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/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..69f0022 100644
--- a/include/rive/wasm/rive_bindings_v1.h
+++ b/include/rive/wasm/rive_bindings_v1.h
@@ -396,6 +396,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 +408,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 +658,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 +779,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..0c1d6d2 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.
@@ -441,6 +445,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 +500,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 +547,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
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/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..8a5be1a 100644
--- a/src/wasm/idl/bindings.py
+++ b/src/wasm/idl/bindings.py
@@ -434,6 +434,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 +451,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 +813,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 +981,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/wasm_natives_gen.hpp b/src/wasm/wasm_natives_gen.hpp
index 08d3b4e..1b729f7 100644
--- a/src/wasm/wasm_natives_gen.hpp
+++ b/src/wasm/wasm_natives_gen.hpp
@@ -291,11 +291,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 +418,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 +504,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);
@@ -981,6 +988,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 +1019,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 +1705,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 +2055,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);
@@ -2175,11 +2222,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 +2367,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 +2479,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 +2573,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..9c82424 100644
--- a/src/wasm/wasm_natives_web_gen.hpp
+++ b/src/wasm/wasm_natives_web_gen.hpp
@@ -564,6 +564,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 +601,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 +1322,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 +1672,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..2330689 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"
@@ -3215,6 +3217,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 +3718,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 +4125,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))
@@ -5927,12 +6032,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 +6070,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 +6136,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 +6150,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 +6239,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 +6393,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 +6412,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) ------------------------
@@ -8731,6 +8906,8 @@
             return "animation";
         case Tag::node:
             return "node";
+        case Tag::transitionChild:
+            return "transitionChild";
         case Tag::audioSource:
             return "audioSource";
         case Tag::audioSound:
@@ -9268,19 +9445,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 +9561,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 +9586,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 +9599,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 +9672,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 +9693,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 +9765,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 +9790,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..a45f6ee 100644
--- a/src/wasm/web/rive_module_imports.mjs
+++ b/src/wasm/web/rive_module_imports.mjs
@@ -276,6 +276,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 +292,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 +641,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 +854,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/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);
+}