| /* |
| * 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 |