Fix issue with timeline events on first frame

Events that fire on the first frame (with a work area too) were not reporting. This was because the way we look for occurred events wouldn't catch an event that started at the same time as the playhead as presumably that would've been caught on the previous frame. This falls apart when the animation starts on the same frame as the one where an event (or multiple) are triggered.

Adds a fix by detecting this condition and a bunch of tests in both C++ and Dart for it.

Also revs the lightning bolt animation in the editor for triggering the event as this showed an issue with state contention in its state machine.

Diffs=
382a48cf8 Fix issue with timeline events on first frame (#6006)

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index 3622949..21826a4 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-236d788ea3cf8f184a026a1b69c14af60003169c
+382a48cf8c79664cc8d5308f2f0a4a504d33b2be
diff --git a/include/rive/animation/keyed_object.hpp b/include/rive/animation/keyed_object.hpp
index 04acdd6..e11306c 100644
--- a/include/rive/animation/keyed_object.hpp
+++ b/include/rive/animation/keyed_object.hpp
@@ -18,7 +18,8 @@
     StatusCode onAddedClean(CoreContext* context) override;
     void reportKeyedCallbacks(KeyedCallbackReporter* reporter,
                               float secondsFrom,
-                              float secondsTo) const;
+                              float secondsTo,
+                              int secondsFromExactOffset) const;
     void apply(Artboard* coreContext, float time, float mix);
 
     StatusCode import(ImportStack& importStack) override;
diff --git a/include/rive/animation/keyed_property.hpp b/include/rive/animation/keyed_property.hpp
index e092cfe..c45f3bd 100644
--- a/include/rive/animation/keyed_property.hpp
+++ b/include/rive/animation/keyed_property.hpp
@@ -19,7 +19,8 @@
     void reportKeyedCallbacks(KeyedCallbackReporter* reporter,
                               uint32_t objectId,
                               float secondsFrom,
-                              float secondsTo) const;
+                              float secondsTo,
+                              int secondsFromExactOffset) const;
 
     /// Apply interpolating key frames.
     void apply(Core* object, float time, float mix);
diff --git a/src/animation/keyed_object.cpp b/src/animation/keyed_object.cpp
index c43e9de..3666199 100644
--- a/src/animation/keyed_object.cpp
+++ b/src/animation/keyed_object.cpp
@@ -45,7 +45,8 @@
 
 void KeyedObject::reportKeyedCallbacks(KeyedCallbackReporter* reporter,
                                        float secondsFrom,
-                                       float secondsTo) const
+                                       float secondsTo,
+                                       int secondsFromExactOffset) const
 {
     for (const std::unique_ptr<KeyedProperty>& property : m_keyedProperties)
     {
@@ -53,7 +54,11 @@
         {
             continue;
         }
-        property->reportKeyedCallbacks(reporter, objectId(), secondsFrom, secondsTo);
+        property->reportKeyedCallbacks(reporter,
+                                       objectId(),
+                                       secondsFrom,
+                                       secondsTo,
+                                       secondsFromExactOffset);
     }
 }
 
diff --git a/src/animation/keyed_property.cpp b/src/animation/keyed_property.cpp
index b200df7..750e976 100644
--- a/src/animation/keyed_property.cpp
+++ b/src/animation/keyed_property.cpp
@@ -49,9 +49,10 @@
 void KeyedProperty::reportKeyedCallbacks(KeyedCallbackReporter* reporter,
                                          uint32_t objectId,
                                          float secondsFrom,
-                                         float secondsTo) const
+                                         float secondsTo,
+                                         int secondsFromExactOffset) const
 {
-    int idx = closestFrameIndex(secondsFrom, 1);
+    int idx = closestFrameIndex(secondsFrom, secondsFromExactOffset);
     int idxTo = closestFrameIndex(secondsTo, 1);
 
     if (idxTo < idx)
diff --git a/src/animation/linear_animation.cpp b/src/animation/linear_animation.cpp
index bb38502..c2ade3b 100644
--- a/src/animation/linear_animation.cpp
+++ b/src/animation/linear_animation.cpp
@@ -121,8 +121,14 @@
                                            float secondsFrom,
                                            float secondsTo) const
 {
+    int secondsFromExactOffset =
+        startTime() == secondsFrom &&
+                (speed() >= 0 ? secondsFrom < secondsTo : secondsFrom < secondsTo)
+            ? 0
+            : 1;
+
     for (const auto& object : m_KeyedObjects)
     {
-        object->reportKeyedCallbacks(reporter, secondsFrom, secondsTo);
+        object->reportKeyedCallbacks(reporter, secondsFrom, secondsTo, secondsFromExactOffset);
     }
 }
\ No newline at end of file
diff --git a/src/animation/linear_animation_instance.cpp b/src/animation/linear_animation_instance.cpp
index 76049f5..b6232d1 100644
--- a/src/animation/linear_animation_instance.cpp
+++ b/src/animation/linear_animation_instance.cpp
@@ -113,6 +113,10 @@
                 frames = start + std::fmod(frames - start, (float)range);
                 m_time = frames / fps;
                 didLoop = true;
+                if (reporter != nullptr)
+                {
+                    animation.reportKeyedCallbacks(reporter, 0.0f, m_time);
+                }
             }
             else if (direction == -1 && frames <= start)
             {
@@ -121,6 +125,10 @@
                 frames = end - std::abs(std::fmod(start - frames, (float)range));
                 m_time = frames / fps;
                 didLoop = true;
+                if (reporter != nullptr)
+                {
+                    animation.reportKeyedCallbacks(reporter, end / (float)fps, m_time);
+                }
             }
             break;
         case Loop::pingPong:
@@ -130,11 +138,13 @@
                 {
                     m_spilledTime = (frames - end) / fps;
                     frames = end + (end - frames);
+                    lastTime = end / (float)fps;
                 }
                 else if (direction == -1 && frames < start)
                 {
                     m_spilledTime = (start - frames) / fps;
                     frames = start + (start - frames);
+                    lastTime = start / (float)fps;
                 }
                 else
                 {
@@ -149,6 +159,10 @@
                 m_direction *= -1;
                 direction *= -1;
                 didLoop = true;
+                if (reporter != nullptr)
+                {
+                    animation.reportKeyedCallbacks(reporter, lastTime, m_time);
+                }
             }
             break;
     }
diff --git a/test/assets/looping_timeline_events.riv b/test/assets/looping_timeline_events.riv
new file mode 100644
index 0000000..f4175b3
--- /dev/null
+++ b/test/assets/looping_timeline_events.riv
Binary files differ
diff --git a/test/linear_animation_test.cpp b/test/linear_animation_test.cpp
index fe59c28..ef23235 100644
--- a/test/linear_animation_test.cpp
+++ b/test/linear_animation_test.cpp
@@ -1,6 +1,7 @@
-#include <rive/artboard.hpp>
-#include <rive/animation/linear_animation.hpp>
-#include <rive/animation/linear_animation_instance.hpp>
+#include "rive/artboard.hpp"
+#include "rive/animation/linear_animation.hpp"
+#include "rive/animation/linear_animation_instance.hpp"
+#include "rive/animation/keyed_callback_reporter.hpp"
 #include "utils/no_op_factory.hpp"
 #include "rive_file_reader.hpp"
 #include "rive_testing.hpp"
@@ -108,4 +109,56 @@
     REQUIRE(animationInstance.time() == 0.7f);
 
     delete linearAnimation;
+}
+
+class TestReporter : public rive::KeyedCallbackReporter
+{
+private:
+    std::vector<uint32_t> m_reportedObjects;
+
+public:
+    void reportKeyedCallback(uint32_t objectId, uint32_t propertyKey, float elapsedSeconds) override
+    {
+        m_reportedObjects.push_back(objectId);
+    }
+
+    size_t count() { return m_reportedObjects.size(); }
+};
+
+TEST_CASE("Looping timeline events load correctly and report", "[events]")
+{
+    auto file = ReadRiveFile("../../test/assets/looping_timeline_events.riv");
+
+    auto artboard = file->artboard()->instance();
+    REQUIRE(artboard != nullptr);
+    REQUIRE(artboard->animationCount() == 1);
+
+    auto animationInstance = artboard->animationAt(0);
+    REQUIRE(animationInstance != nullptr);
+
+    TestReporter reporter;
+    // Report (at frame 0) fires from 0-0.1f
+    animationInstance->advance(0.1f, &reporter);
+    REQUIRE(animationInstance->time() == 0.1f);
+    REQUIRE(reporter.count() == 1);
+
+    // Report (at frame 25) fires
+    animationInstance->advance(0.32f, &reporter);
+    REQUIRE(animationInstance->time() == 0.42f);
+    REQUIRE(reporter.count() == 2);
+
+    // No report from .42 to .72 seconds.
+    animationInstance->advance(0.3f, &reporter);
+    REQUIRE(animationInstance->time() == 0.72f);
+    REQUIRE(reporter.count() == 2);
+
+    // Report at 1s fires from .72 to 1.0 seconds.
+    animationInstance->advance(0.28f, &reporter);
+    REQUIRE(animationInstance->time() == 0.0f);
+    REQUIRE(reporter.count() == 3);
+
+    // All 3 events fire (and first one again too).
+    animationInstance->advance(1.01f, &reporter);
+    REQUIRE(animationInstance->time() == Approx(0.01f));
+    REQUIRE(reporter.count() == 7);
 }
\ No newline at end of file