blob: 98e2c1762ef913e6ba0387857b2b0f0c3aff3e1d [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>
#include <vector>
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);
paint->additiveness(0.375f);
paint->strokePosition(StrokePosition::outside);
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());
// Images carry additiveness per draw rather than on a paint, and a non
// zero value records as a different op, so both branches need covering.
rendererA->drawImage(image.get(),
ImageSampler::LinearClamp(),
BlendMode::srcOver,
1.0f);
rendererA->drawImage(image.get(),
ImageSampler::LinearClamp(),
BlendMode::srcOver,
1.0f,
0.625f);
float verts[8] = {0, 0, 10, 0, 10, 10, 0, 10};
float uvs[8] = {0, 0, 1, 0, 1, 1, 0, 1};
uint16_t indices[6] = {0, 1, 2, 0, 2, 3};
auto vertexBuffer = a.makeRenderBuffer(RenderBufferType::vertex,
RenderBufferFlags::none,
sizeof(verts));
auto uvBuffer = a.makeRenderBuffer(RenderBufferType::vertex,
RenderBufferFlags::none,
sizeof(uvs));
auto indexBuffer = a.makeRenderBuffer(RenderBufferType::index,
RenderBufferFlags::none,
sizeof(indices));
memcpy(vertexBuffer->map(), verts, sizeof(verts));
vertexBuffer->unmap();
memcpy(uvBuffer->map(), uvs, sizeof(uvs));
uvBuffer->unmap();
memcpy(indexBuffer->map(), indices, sizeof(indices));
indexBuffer->unmap();
rendererA->drawImageMesh(image.get(),
ImageSampler::LinearClamp(),
vertexBuffer,
uvBuffer,
indexBuffer,
4,
6,
BlendMode::srcOver,
1.0f);
rendererA->drawImageMesh(image.get(),
ImageSampler::LinearClamp(),
vertexBuffer,
uvBuffer,
indexBuffer,
4,
6,
BlendMode::srcOver,
1.0f,
0.625f);
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);
}
namespace
{
// Records one instanced mesh draw, with every per instance field set to
// something distinct so a swapped pair shows up.
struct InstancedMesh
{
rcp<RenderImage> image;
rcp<RenderBuffer> pts, uvs, indices;
rcp<ImageMeshInstances> instances;
static constexpr size_t Count = 3;
};
rcp<RenderBuffer> recordBuffer(SerializingFactory& f,
RenderBufferType type,
const void* source,
size_t size)
{
auto buffer = f.makeRenderBuffer(type, RenderBufferFlags::none, size);
memcpy(buffer->map(), source, size);
buffer->unmap();
return buffer;
}
InstancedMesh recordInstancedMesh(SerializingFactory& f, Renderer* renderer)
{
InstancedMesh mesh;
auto imageBytes = ReadFile("assets/open_source.jpg");
mesh.image = f.decodeImage(imageBytes);
REQUIRE(mesh.image != nullptr);
const Vec2D quad[4] = {{0, 0}, {10, 0}, {10, 10}, {0, 10}};
const Vec2D uv[4] = {{0, 0}, {1, 0}, {1, 1}, {0, 1}};
const uint16_t idx[6] = {0, 1, 2, 0, 2, 3};
mesh.pts = recordBuffer(f, RenderBufferType::vertex, quad, sizeof(quad));
mesh.uvs = recordBuffer(f, RenderBufferType::vertex, uv, sizeof(uv));
mesh.indices = recordBuffer(f, RenderBufferType::index, idx, sizeof(idx));
mesh.instances = f.makeImageMeshInstances(InstancedMesh::Count);
Span<ImageMeshInstanceData> data = mesh.instances->edit();
data[0].transform = Mat2D(1, 0, 0, 1, 10, 20);
data[1].opacity = 0.5f;
data[2].uvTranslate = {0.25f, 0.5f};
data[2].uvScale = {0.5f, 0.25f};
data[2].additiveness = 1.0f;
mesh.instances->endEdit();
renderer->drawImageMeshInstanced(mesh.image.get(),
ImageSampler::LinearClamp(),
mesh.pts,
mesh.uvs,
mesh.indices,
4,
6,
mesh.instances);
return mesh;
}
// Keeps what the instanced draw delivered, and counts the plain mesh draws a
// fallback would have produced instead.
class MeshRecordingRenderer : public NoOpRenderer
{
public:
int instancedDraws = 0;
int plainDraws = 0;
std::vector<ImageMeshInstanceData> got;
void drawImageMesh(const RenderImage*,
ImageSampler,
rcp<RenderBuffer>,
rcp<RenderBuffer>,
rcp<RenderBuffer>,
uint32_t,
uint32_t,
BlendMode,
float) override
{
++plainDraws;
}
void drawImageMeshInstanced(const RenderImage*,
ImageSampler,
rcp<RenderBuffer>,
rcp<RenderBuffer>,
rcp<RenderBuffer>,
uint32_t,
uint32_t,
rcp<ImageMeshInstances> instances) override
{
++instancedDraws;
got.assign(instances->instanceData().begin(),
instances->instanceData().end());
}
};
} // namespace
TEST_CASE("an instanced mesh draw replays byte-identically",
"[serialize][replay]")
{
SerializingFactory a;
a.frameSize(256, 256);
a.addFrame();
auto rendererA = a.makeRenderer();
recordInstancedMesh(a, rendererA.get());
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);
}
// Byte identity only proves the stream survives a rewrite; the writer and
// reader would agree on a swapped pair of fields. Check the values land where
// they were put, and that the draw stays one call rather than fanning out.
TEST_CASE("replayed instances keep their data", "[serialize][replay]")
{
SerializingFactory a;
a.frameSize(256, 256);
a.addFrame();
auto rendererA = a.makeRenderer();
recordInstancedMesh(a, rendererA.get());
SerializingFactory b;
MeshRecordingRenderer sink;
REQUIRE(replaySerializedCommands(a.bytes(), &b, &sink));
CHECK(sink.instancedDraws == 1);
CHECK(sink.plainDraws == 0);
REQUIRE(sink.got.size() == InstancedMesh::Count);
CHECK(sink.got[0].transform[4] == 10.0f);
CHECK(sink.got[0].transform[5] == 20.0f);
CHECK(sink.got[1].opacity == 0.5f);
CHECK(sink.got[2].uvTranslate.x == 0.25f);
CHECK(sink.got[2].uvTranslate.y == 0.5f);
CHECK(sink.got[2].uvScale.x == 0.5f);
CHECK(sink.got[2].uvScale.y == 0.25f);
CHECK(sink.got[2].additiveness == 1.0f);
// Untouched entries arrive at the defaults, not zeroed.
CHECK(sink.got[0].opacity == 1.0f);
CHECK(sink.got[0].uvScale.x == 1.0f);
CHECK(sink.got[0].uvScale.y == 1.0f);
}
TEST_CASE("a centered stroke records no stroke position op",
"[serialize][replay]")
{
// Every stroke in an existing file is centered, so recording one has to
// leave the stream byte for byte what it was before the op existed, or
// every silver with a stroke in it would need regenerating.
SerializingFactory plain;
auto plainPaint = plain.makeRenderPaint();
plainPaint->style(RenderPaintStyle::stroke);
SerializingFactory centered;
auto centeredPaint = centered.makeRenderPaint();
centeredPaint->style(RenderPaintStyle::stroke);
centeredPaint->strokePosition(StrokePosition::center);
auto sa = plain.bytes();
auto sb = centered.bytes();
REQUIRE(sa.size() == sb.size());
CHECK(std::memcmp(sa.data(), sb.data(), sa.size()) == 0);
}
TEST_CASE("a replayed paint keeps its stroke position", "[serialize][replay]")
{
SerializingFactory a;
auto paint = a.makeRenderPaint();
paint->style(RenderPaintStyle::stroke);
paint->strokePosition(StrokePosition::inside);
auto recorded = a.bytes().size();
// Setting the same value again is deduped like every other paint op.
paint->strokePosition(StrokePosition::inside);
CHECK(a.bytes().size() == recorded);
paint->strokePosition(StrokePosition::outside);
CHECK(a.bytes().size() > recorded);
SerializingFactory b;
auto r = b.makeRenderer();
REQUIRE(replaySerializedCommands(a.bytes(), &b, r.get()));
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("an unknown stroke position fails replay", "[serialize][replay]")
{
// Renderers assert on a position they don't know, so replay has to stop
// at it rather than pass it through.
SerializingFactory a;
auto paint = a.makeRenderPaint();
paint->strokePosition(StrokePosition::outside);
std::vector<uint8_t> stream(a.bytes().begin(), a.bytes().end());
// The op ends the stream: [strokePosition][paint id][value].
REQUIRE(stream.back() == (uint8_t)StrokePosition::outside);
stream.back() = 7;
SerializingFactory b;
auto r = b.makeRenderer();
CHECK_FALSE(replaySerializedCommands(
Span<const uint8_t>(stream.data(), stream.size()),
&b,
r.get()));
}
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));
}