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)) {