blob: 6b14ff2e3bbdeffcd254aab71c37c7dd7818b6a8 [file]
/*
* 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