blob: a1e23fba11a5058f47877058389120b65f5ab6d0 [file]
/*
* Copyright 2026 Rive
*/
// Replays a SerializingFactory stream into a second SerializingFactory and
// asserts the re-recorded stream is byte identical, proving every call is
// reproduced in order. GPU free, pixels are covered by the GMs.
#include "utils/serializing_factory.hpp"
#include "utils/serialized_replay.hpp"
#include "rive/math/raw_path.hpp"
#include "rive/math/mat2d.hpp"
#include "rive_file_reader.hpp"
#include "rive/renderer/render_canvas.hpp"
#include "utils/no_op_renderer.hpp"
#include <catch.hpp>
#include <cstring>
using namespace rive;
TEST_CASE("serialized 2D commands replay byte-identically",
"[serialize][replay]")
{
SerializingFactory a;
a.frameSize(256, 256);
a.addFrame();
auto rendererA = a.makeRenderer();
// Exercises every paint mutation op.
auto paint = a.makeRenderPaint();
paint->color(0xFF112233);
paint->style(RenderPaintStyle::stroke);
paint->thickness(3.5f);
paint->join(StrokeJoin::round);
paint->cap(StrokeCap::square);
paint->blendMode(BlendMode::multiply);
paint->feather(2.0f);
RawPath rp;
rp.move({0, 0});
rp.line({10, 0});
rp.cubic({10, 5}, {5, 10}, {0, 10});
rp.close();
auto path = a.makeRenderPath(rp, FillRule::evenOdd);
auto clip = a.makeEmptyRenderPath();
RawPath cp;
cp.move({0, 0});
cp.line({20, 0});
cp.line({20, 20});
cp.close();
clip->addRawPath(cp);
ColorInt cols[2] = {0xFFFF0000, 0xFF0000FF};
float stops[2] = {0.0f, 1.0f};
auto grad = a.makeLinearGradient(0, 0, 100, 100, cols, stops, 2);
auto paint2 = a.makeRenderPaint();
paint2->shader(grad);
// Modulating image: non-default sampler on every axis and an asymmetric
// matrix, so a mis-ordered read/write of either shows up in the diff.
auto imageBytes = ReadFile("assets/open_source.jpg");
auto image = a.decodeImage(imageBytes);
REQUIRE(image != nullptr);
ImageSampler sampler;
sampler.filter = ImageFilter::nearest;
sampler.wrapX = ImageWrap::repeat;
sampler.wrapY = ImageWrap::mirror;
paint2->modulatedImage(image.get(), sampler, Mat2D(2, 3, 4, 5, 6, 7));
// ...and the clear (null image) path, which writes image id 0.
auto paint3 = a.makeRenderPaint();
paint3->modulatedImage(image.get(), sampler, Mat2D(1, 0, 0, 1, 0, 0));
paint3->modulatedImage(nullptr, ImageSampler::LinearClamp(), Mat2D());
rendererA->save();
rendererA->transform(Mat2D(1, 0, 0, 1, 5, 7));
rendererA->clipPath(clip.get());
rendererA->modulateOpacity(0.5f);
rendererA->drawPath(path.get(), paint.get());
rendererA->drawPath(path.get(), paint2.get());
rendererA->drawPath(path.get(), paint3.get());
rendererA->restore();
SerializingFactory b;
auto rendererB = b.makeRenderer();
SerializedReplayHooks hooks;
hooks.onFrame = [&]() { b.addFrame(); };
hooks.onFrameSize = [&](uint32_t w, uint32_t h) { b.frameSize(w, h); };
REQUIRE(replaySerializedCommands(a.bytes(), &b, rendererB.get(), hooks));
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("serialized replay rejects a bad header", "[serialize][replay]")
{
const uint8_t garbage[8] = {'X', 'X', 'X', 'X', 1, 0, 0, 0};
SerializingFactory b;
auto r = b.makeRenderer();
CHECK_FALSE(
replaySerializedCommands(Span<const uint8_t>(garbage, sizeof(garbage)),
&b,
r.get()));
}
// ---- cache-as-bitmap: offscreen canvas content recorded inline ----
//
// The stream declares a canvas, records its content between
// canvasContentBegin/End, and composites it with an ordinary drawImage of the
// canvas id. Constructing a RenderCanvas allocates nothing, so these stay GPU
// free -- no render context is needed to exercise the protocol.
namespace
{
// Records one stream: an offscreen canvas drawn into, then composited, with a
// plain vector draw on either side of it.
void recordCachedStream(SerializingFactory& f, Renderer* screen)
{
auto paint = f.makeRenderPaint();
paint->color(0xFF445566);
RawPath rp;
rp.move({0, 0});
rp.line({10, 0});
rp.line({10, 10});
rp.close();
auto path = f.makeRenderPath(rp, FillRule::nonZero);
// Before the canvas, so a dropped bracket is distinguishable from a
// dropped frame.
screen->drawPath(path.get(), paint.get());
auto canvas = make_rcp<gpu::RenderCanvas>(64, 48);
Renderer* content = f.beginCanvasContent(canvas.get(), 0xFF204060);
REQUIRE(content != nullptr);
content->save();
content->transform(Mat2D::fromScale(2.0f, 2.0f));
content->drawPath(path.get(), paint.get());
content->restore();
f.endCanvasContent(canvas.get());
// The composite: an ordinary drawImage of the canvas id.
screen->save();
screen->drawImage(f.contentCanvasImage(canvas.get()).get(),
ImageSampler::LinearClamp(),
BlendMode::srcOver,
1.0f);
screen->restore();
}
// Counts what actually reaches the screen renderer during a replay.
class CountingRenderer : public NoOpRenderer
{
public:
void drawPath(RenderPath*, RenderPaint*) override { paths++; }
void drawImage(const RenderImage*, ImageSampler, BlendMode, float) override
{
images++;
}
int paths = 0;
int images = 0;
};
} // namespace
TEST_CASE("canvas content replays byte-identically", "[serialize][replay]")
{
SerializingFactory a;
a.frameSize(256, 256);
a.addFrame();
auto rendererA = a.makeRenderer();
recordCachedStream(a, rendererA.get());
SerializingFactory b;
auto rendererB = b.makeRenderer();
// A host with an offscreen target: it mints its own canvas for the id the
// stream named and hands back the renderer its content records into.
std::vector<rcp<gpu::RenderCanvas>> canvasesB;
int begins = 0, ends = 0;
SerializedReplayHooks hooks;
hooks.onFrame = [&]() { b.addFrame(); };
hooks.onFrameSize = [&](uint32_t w, uint32_t h) { b.frameSize(w, h); };
hooks.onCanvasContentBegin = [&](uint64_t,
uint32_t w,
uint32_t h,
uint32_t clearColor,
rcp<RenderImage>* image) -> Renderer* {
begins++;
canvasesB.push_back(make_rcp<gpu::RenderCanvas>(w, h));
gpu::RenderCanvas* canvas = canvasesB.back().get();
Renderer* content = b.beginCanvasContent(canvas, clearColor);
*image = b.contentCanvasImage(canvas);
return content;
};
hooks.onCanvasContentEnd = [&](uint64_t) {
ends++;
b.endCanvasContent(canvasesB.back().get());
};
REQUIRE(replaySerializedCommands(a.bytes(), &b, rendererB.get(), hooks));
CHECK(begins == 1);
CHECK(ends == 1);
REQUIRE(canvasesB.size() == 1);
CHECK(canvasesB.front()->width() == 64);
CHECK(canvasesB.front()->height() == 48);
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("a host with no offscreen target drops the canvas and keeps going",
"[serialize][replay]")
{
SerializingFactory a;
a.frameSize(256, 256);
a.addFrame();
auto rendererA = a.makeRenderer();
recordCachedStream(a, rendererA.get());
// No onCanvasContentBegin at all: the documented "this host cannot open an
// offscreen frame" case.
SerializingFactory b;
CountingRenderer screen;
REQUIRE(replaySerializedCommands(a.bytes(), &b, &screen));
// The plain draw outside the bracket still replays; the canvas's own draw
// is dropped rather than leaking onto the screen, and the composite finds
// no image to sample.
CHECK(screen.paths == 1);
CHECK(screen.images == 0);
}
TEST_CASE("a canvas end naming the wrong canvas fails replay",
"[serialize][replay]")
{
// Bracket depth used to be the only thing checked, so an end whose id did
// not match its begin still closed the innermost canvas, announced an
// unrelated id to the host, and reported success.
SerializingFactory a;
a.frameSize(256, 256);
a.addFrame();
auto rendererA = a.makeRenderer();
auto paint = a.makeRenderPaint();
RawPath rp;
rp.move({0, 0});
rp.line({10, 10});
auto path = a.makeRenderPath(rp, FillRule::nonZero);
// A complete, well-formed bracket first. Its only job is to give `other` a
// declared id before it is misused below: beginCanvasContent mints ids,
// and minting one from inside endCanvasContent would interleave a
// makeRenderCanvas op into the middle of the end op and fail replay for an
// unrelated reason.
auto other = make_rcp<gpu::RenderCanvas>(4, 4);
Renderer* otherContent = a.beginCanvasContent(other.get(), 0);
otherContent->drawPath(path.get(), paint.get());
a.endCanvasContent(other.get());
auto canvas = make_rcp<gpu::RenderCanvas>(8, 8);
Renderer* content = a.beginCanvasContent(canvas.get(), 0);
content->drawPath(path.get(), paint.get());
// Closes the open bracket with the other canvas's id.
a.endCanvasContent(other.get());
SerializingFactory b;
CountingRenderer screen;
int endCount = 0;
SerializedReplayHooks hooks;
hooks.onCanvasContentBegin =
[&](uint64_t, uint32_t, uint32_t, uint32_t, rcp<RenderImage>*)
-> Renderer* { return &screen; };
hooks.onCanvasContentEnd = [&](uint64_t) { endCount++; };
CHECK_FALSE(replaySerializedCommands(a.bytes(), &b, &screen, hooks));
// The well-formed bracket closed; the mismatched end was rejected before
// it could tell the host to close a canvas that was never open.
CHECK(endCount == 1);
}
TEST_CASE("a stream cut inside canvas content fails replay",
"[serialize][replay]")
{
SerializingFactory a;
a.frameSize(256, 256);
a.addFrame();
auto rendererA = a.makeRenderer();
auto paint = a.makeRenderPaint();
RawPath rp;
rp.move({0, 0});
rp.line({10, 10});
auto path = a.makeRenderPath(rp, FillRule::nonZero);
auto canvas = make_rcp<gpu::RenderCanvas>(8, 8);
Renderer* content = a.beginCanvasContent(canvas.get(), 0);
content->drawPath(path.get(), paint.get());
// Deliberately no endCanvasContent.
SerializingFactory b;
CountingRenderer screen;
SerializedReplayHooks hooks;
hooks.onCanvasContentBegin =
[&](uint64_t, uint32_t, uint32_t, uint32_t, rcp<RenderImage>*)
-> Renderer* { return &screen; };
CHECK_FALSE(replaySerializedCommands(a.bytes(), &b, &screen, hooks));
}