| /* | |
| * pointer_log_replay.hpp | |
| * | |
| * Loads a pointer-interaction log recorded by the Rive viewer (see | |
| * packages/viewer InteractionRecorder) and replays it against a | |
| * SerializingFactory to drive a "silver" test at runtime -- an alternative to | |
| * generating C++ ahead of time with tools/pointer_log_to_silver.js. | |
| * | |
| * The replay follows the SAME cadence + quantization rules as that generator, | |
| * so a JSON-driven test and a generated-code test produce the identical .sriv | |
| * stream (and therefore the same golden). | |
| * | |
| * Usage: | |
| * SerializingFactory silver; | |
| * auto file = ReadRiveFile("assets/foo.riv", &silver); | |
| * auto artboard = file->artboardDefault(); | |
| * silver.frameSize(artboard->width(), artboard->height()); | |
| * auto sm = artboard->stateMachineAt(0); | |
| * // ... bind view model, prime, initial draw (mirror the generator preamble) | |
| * auto renderer = silver.makeRenderer(); | |
| * artboard->draw(renderer.get()); | |
| * replayPointerLog("assets/foo.json", silver, artboard.get(), sm.get(), | |
| * renderer.get(), {.stride = 1}); | |
| * CHECK(silver.matches("foo")); | |
| */ | |
| #ifndef _RIVE_POINTER_LOG_REPLAY_HPP_ | |
| #define _RIVE_POINTER_LOG_REPLAY_HPP_ | |
| #include "rive/animation/state_machine_instance.hpp" | |
| #include "rive/artboard.hpp" | |
| #include "rive/math/vec2d.hpp" | |
| #include "rive/renderer.hpp" | |
| #include "utils/serializing_factory.hpp" | |
| #include "rive_testing.hpp" | |
| #include <algorithm> | |
| #include <cmath> | |
| #include <cstdlib> | |
| #include <fstream> | |
| #include <sstream> | |
| #include <string> | |
| #include <utility> | |
| #include <vector> | |
| namespace rive | |
| { | |
| namespace pointer_log | |
| { | |
| // --------------------------------------------------------------------------- | |
| // Minimal JSON value + parser (sufficient for the recorder's schema: an object | |
| // with a "meta" object and an "entries" array of flat objects whose values are | |
| // strings, numbers, and booleans). Not a general-purpose JSON library. | |
| // | |
| // Entry ops: | |
| // {op:"pointer", type:"down|move|up|exit", x, y, id} | |
| // {op:"advance", dt} -- one frame | |
| // {op:"multiAdvance", count, dt} -- `count` frames of `dt` each | |
| // --------------------------------------------------------------------------- | |
| struct Json | |
| { | |
| enum class Type | |
| { | |
| null, | |
| boolean, | |
| number, | |
| string, | |
| array, | |
| object | |
| }; | |
| Type type = Type::null; | |
| bool boolean = false; | |
| double number = 0; | |
| std::string str; | |
| std::vector<Json> array; | |
| std::vector<std::pair<std::string, Json>> object; | |
| const Json* find(const char* key) const | |
| { | |
| for (const auto& kv : object) | |
| { | |
| if (kv.first == key) | |
| { | |
| return &kv.second; | |
| } | |
| } | |
| return nullptr; | |
| } | |
| double numberOr(double fallback) const | |
| { | |
| return type == Type::number ? number : fallback; | |
| } | |
| bool boolOr(bool fallback) const | |
| { | |
| return type == Type::boolean ? boolean : fallback; | |
| } | |
| std::string stringOr(const char* fallback) const | |
| { | |
| return type == Type::string ? str : std::string(fallback); | |
| } | |
| }; | |
| class Parser | |
| { | |
| public: | |
| Parser(const char* begin, const char* end) : m_p(begin), m_end(end) {} | |
| bool parse(Json& out) | |
| { | |
| skipWs(); | |
| if (!parseValue(out)) | |
| { | |
| return false; | |
| } | |
| skipWs(); | |
| return m_ok; | |
| } | |
| private: | |
| const char* m_p; | |
| const char* m_end; | |
| bool m_ok = true; | |
| bool atEnd() const { return m_p >= m_end; } | |
| char peek() const { return atEnd() ? '\0' : *m_p; } | |
| void skipWs() | |
| { | |
| while (!atEnd()) | |
| { | |
| char c = *m_p; | |
| if (c == ' ' || c == '\t' || c == '\n' || c == '\r') | |
| { | |
| m_p++; | |
| } | |
| else | |
| { | |
| break; | |
| } | |
| } | |
| } | |
| bool expect(char c) | |
| { | |
| if (peek() != c) | |
| { | |
| m_ok = false; | |
| return false; | |
| } | |
| m_p++; | |
| return true; | |
| } | |
| bool parseValue(Json& out) | |
| { | |
| skipWs(); | |
| char c = peek(); | |
| switch (c) | |
| { | |
| case '{': | |
| return parseObject(out); | |
| case '[': | |
| return parseArray(out); | |
| case '"': | |
| return parseString(out); | |
| case 't': | |
| case 'f': | |
| return parseBool(out); | |
| case 'n': | |
| return parseNull(out); | |
| default: | |
| return parseNumber(out); | |
| } | |
| } | |
| bool parseObject(Json& out) | |
| { | |
| out.type = Json::Type::object; | |
| if (!expect('{')) | |
| { | |
| return false; | |
| } | |
| skipWs(); | |
| if (peek() == '}') | |
| { | |
| m_p++; | |
| return true; | |
| } | |
| while (true) | |
| { | |
| skipWs(); | |
| Json key; | |
| if (!parseString(key)) | |
| { | |
| return false; | |
| } | |
| skipWs(); | |
| if (!expect(':')) | |
| { | |
| return false; | |
| } | |
| Json value; | |
| if (!parseValue(value)) | |
| { | |
| return false; | |
| } | |
| out.object.emplace_back(key.str, std::move(value)); | |
| skipWs(); | |
| if (peek() == ',') | |
| { | |
| m_p++; | |
| continue; | |
| } | |
| return expect('}'); | |
| } | |
| } | |
| bool parseArray(Json& out) | |
| { | |
| out.type = Json::Type::array; | |
| if (!expect('[')) | |
| { | |
| return false; | |
| } | |
| skipWs(); | |
| if (peek() == ']') | |
| { | |
| m_p++; | |
| return true; | |
| } | |
| while (true) | |
| { | |
| Json value; | |
| if (!parseValue(value)) | |
| { | |
| return false; | |
| } | |
| out.array.push_back(std::move(value)); | |
| skipWs(); | |
| if (peek() == ',') | |
| { | |
| m_p++; | |
| continue; | |
| } | |
| return expect(']'); | |
| } | |
| } | |
| bool parseString(Json& out) | |
| { | |
| out.type = Json::Type::string; | |
| if (!expect('"')) | |
| { | |
| return false; | |
| } | |
| std::string s; | |
| while (!atEnd()) | |
| { | |
| char c = *m_p++; | |
| if (c == '"') | |
| { | |
| out.str = std::move(s); | |
| return true; | |
| } | |
| if (c == '\\') | |
| { | |
| if (atEnd()) | |
| { | |
| break; | |
| } | |
| char e = *m_p++; | |
| switch (e) | |
| { | |
| case '"': | |
| s.push_back('"'); | |
| break; | |
| case '\\': | |
| s.push_back('\\'); | |
| break; | |
| case '/': | |
| s.push_back('/'); | |
| break; | |
| case 'n': | |
| s.push_back('\n'); | |
| break; | |
| case 't': | |
| s.push_back('\t'); | |
| break; | |
| case 'r': | |
| s.push_back('\r'); | |
| break; | |
| case 'b': | |
| s.push_back('\b'); | |
| break; | |
| case 'f': | |
| s.push_back('\f'); | |
| break; | |
| case 'u': | |
| // Skip the 4 hex digits; the recorder never emits | |
| // these. | |
| for (int i = 0; i < 4 && !atEnd(); i++) | |
| { | |
| m_p++; | |
| } | |
| s.push_back('?'); | |
| break; | |
| default: | |
| s.push_back(e); | |
| break; | |
| } | |
| } | |
| else | |
| { | |
| s.push_back(c); | |
| } | |
| } | |
| m_ok = false; | |
| return false; | |
| } | |
| bool parseNumber(Json& out) | |
| { | |
| const char* start = m_p; | |
| if (peek() == '-') | |
| { | |
| m_p++; | |
| } | |
| bool any = false; | |
| while (!atEnd()) | |
| { | |
| char c = *m_p; | |
| if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || | |
| c == '+' || c == '-') | |
| { | |
| any = true; | |
| m_p++; | |
| } | |
| else | |
| { | |
| break; | |
| } | |
| } | |
| if (!any) | |
| { | |
| m_ok = false; | |
| return false; | |
| } | |
| out.type = Json::Type::number; | |
| out.number = std::strtod(std::string(start, m_p).c_str(), nullptr); | |
| return true; | |
| } | |
| bool parseBool(Json& out) | |
| { | |
| if (matchLiteral("true")) | |
| { | |
| out.type = Json::Type::boolean; | |
| out.boolean = true; | |
| return true; | |
| } | |
| if (matchLiteral("false")) | |
| { | |
| out.type = Json::Type::boolean; | |
| out.boolean = false; | |
| return true; | |
| } | |
| m_ok = false; | |
| return false; | |
| } | |
| bool parseNull(Json& out) | |
| { | |
| if (matchLiteral("null")) | |
| { | |
| out.type = Json::Type::null; | |
| return true; | |
| } | |
| m_ok = false; | |
| return false; | |
| } | |
| bool matchLiteral(const char* literal) | |
| { | |
| const char* q = m_p; | |
| while (*literal) | |
| { | |
| if (q >= m_end || *q != *literal) | |
| { | |
| return false; | |
| } | |
| q++; | |
| literal++; | |
| } | |
| m_p = q; | |
| return true; | |
| } | |
| }; | |
| } // namespace pointer_log | |
| /// Cadence options; mirror the tools/pointer_log_to_silver.js flags. Every | |
| /// advance is always applied at its (quantized) recorded dt so animation state | |
| /// stays faithful; these only control how often a frame is recorded. | |
| struct PointerLogReplayOptions | |
| { | |
| /// Seconds between snapshots. When > 0 this takes precedence over `stride`. | |
| float interval = 0.0f; | |
| /// Convenience: when > 0, interval = 1 / fps. | |
| float fps = 0.0f; | |
| /// Count-based cadence: snapshot every Nth advance (used when interval <= | |
| /// 0). | |
| int stride = 1; | |
| /// dt bucket size for grouping near-equal advances. <= 0 disables grouping | |
| /// (each distinct exact dt is emitted as-is; only identical dts group). | |
| float quantize = 0.001f; | |
| }; | |
| /// Replays the entries of a recorded pointer log against `silver`, driving | |
| /// `sm` / `artboard` and recording frames into `renderer`. | |
| static inline void replayPointerLog(const char* jsonPath, | |
| SerializingFactory& silver, | |
| Artboard* artboard, | |
| StateMachineInstance* sm, | |
| Renderer* renderer, | |
| const PointerLogReplayOptions& opts = {}) | |
| { | |
| // Read the whole file. | |
| std::ifstream stream(jsonPath, std::ios::binary); | |
| REQUIRE(stream.good()); | |
| std::stringstream buffer; | |
| buffer << stream.rdbuf(); | |
| std::string text = buffer.str(); | |
| pointer_log::Json root; | |
| pointer_log::Parser parser(text.data(), text.data() + text.size()); | |
| REQUIRE(parser.parse(root)); | |
| const pointer_log::Json* entries = root.find("entries"); | |
| REQUIRE(entries != nullptr); | |
| REQUIRE(entries->type == pointer_log::Json::Type::array); | |
| // Resolve cadence. | |
| float interval = opts.interval; | |
| if (opts.fps > 0.0f) | |
| { | |
| interval = 1.0f / opts.fps; | |
| } | |
| const bool useInterval = interval > 0.0f; | |
| const int stride = opts.stride < 1 ? 1 : opts.stride; | |
| const float quantize = opts.quantize; | |
| // Bucket a dt (> 0) into a grouping key and the float we emit for it. | |
| // `q` is guaranteed > 0 so `interval / q` can never divide by zero: | |
| // - quantize > 0: round to the nearest bucket, clamped to >= 1 bucket so | |
| // a tiny dt still yields q == quantize rather than 0. | |
| // - quantize <= 0: grouping disabled -- use the exact dt (always > 0 | |
| // here); only advances with identical dt group together. | |
| auto bucketFor = [&](double dt) -> std::pair<double, float> { | |
| if (quantize > 0.0f) | |
| { | |
| long bucket = std::lround(dt / quantize); | |
| if (bucket < 1) | |
| { | |
| bucket = 1; | |
| } | |
| return {(double)bucket, (float)bucket * quantize}; | |
| } | |
| return {dt, (float)dt}; | |
| }; | |
| // Open advance group state: a run of consecutive advances sharing | |
| // `groupKey` (and thus the emitted `groupQ`). | |
| double groupKey = 0; | |
| float groupQ = 0.0f; | |
| bool haveGroup = false; | |
| int groupCount = 0; | |
| auto flushGroup = [&]() { | |
| if (!haveGroup || groupCount == 0) | |
| { | |
| haveGroup = false; | |
| groupCount = 0; | |
| return; | |
| } | |
| const float q = groupQ; // always > 0 | |
| const int step = | |
| useInterval ? std::max(1, (int)std::lround(interval / q)) : stride; | |
| const int periods = groupCount / step; | |
| const int remainder = groupCount - periods * step; | |
| for (int p = 0; p < periods; p++) | |
| { | |
| silver.addFrame(); | |
| for (int s = 0; s < step; s++) | |
| { | |
| sm->advanceAndApply(q); | |
| } | |
| artboard->draw(renderer); | |
| } | |
| // Trailing advances that don't complete a snapshot period: apply time | |
| // without recording a frame. | |
| for (int r = 0; r < remainder; r++) | |
| { | |
| sm->advanceAndApply(q); | |
| } | |
| haveGroup = false; | |
| groupCount = 0; | |
| }; | |
| // Feed one advance of `dt` into the grouping state (a `multiAdvance` entry | |
| // calls this `count` times). | |
| auto handleAdvance = [&](double dt) { | |
| if (dt <= 0) | |
| { | |
| // Zero-length advance (drain): apply without a frame. | |
| flushGroup(); | |
| sm->advanceAndApply(0.0f); | |
| return; | |
| } | |
| const auto bq = bucketFor(dt); | |
| const double key = bq.first; | |
| const float q = bq.second; | |
| if (!haveGroup) | |
| { | |
| haveGroup = true; | |
| groupKey = key; | |
| groupQ = q; | |
| groupCount = 1; | |
| } | |
| else if (key == groupKey) | |
| { | |
| groupCount++; | |
| } | |
| else | |
| { | |
| flushGroup(); | |
| haveGroup = true; | |
| groupKey = key; | |
| groupQ = q; | |
| groupCount = 1; | |
| } | |
| }; | |
| for (const auto& e : entries->array) | |
| { | |
| const pointer_log::Json* opJson = e.find("op"); | |
| if (opJson == nullptr) | |
| { | |
| continue; | |
| } | |
| const std::string op = opJson->stringOr(""); | |
| if (op == "pointer") | |
| { | |
| flushGroup(); | |
| const pointer_log::Json* typeJson = e.find("type"); | |
| const std::string type = | |
| typeJson ? typeJson->stringOr("") : std::string(); | |
| const float x = (float)(e.find("x") ? e.find("x")->numberOr(0) : 0); | |
| const float y = (float)(e.find("y") ? e.find("y")->numberOr(0) : 0); | |
| const int id = (int)(e.find("id") ? e.find("id")->numberOr(0) : 0); | |
| const Vec2D pos(x, y); | |
| if (type == "down") | |
| { | |
| sm->pointerDown(pos, id); | |
| } | |
| else if (type == "up") | |
| { | |
| sm->pointerUp(pos, id); | |
| } | |
| else if (type == "move") | |
| { | |
| sm->pointerMove(pos, 0, id); | |
| } | |
| else if (type == "exit") | |
| { | |
| sm->pointerExit(pos, id); | |
| } | |
| } | |
| else if (op == "advance") | |
| { | |
| handleAdvance(e.find("dt") ? e.find("dt")->numberOr(0) : 0); | |
| } | |
| else if (op == "multiAdvance") | |
| { | |
| // A run of identical-delta advances: {count, dt}. | |
| const double dt = e.find("dt") ? e.find("dt")->numberOr(0) : 0; | |
| const long count = | |
| (long)(e.find("count") ? e.find("count")->numberOr(0) : 0); | |
| for (long i = 0; i < count; i++) | |
| { | |
| handleAdvance(dt); | |
| } | |
| } | |
| } | |
| flushGroup(); | |
| } | |
| } // namespace rive | |
| #endif |