.rev-erse Engineering

May 22, 2026

A few months ago, I was presented with a task of converting some custom visual joysticks in a character rig to Rive’s native joysticks layer. It was suspected (by the Rive team and others) that the visual joysticks were causing performance troubles, specifically on lower end devices, as they added an abstraction on top of the Rive-native ones. It should also be noted that they were built on a flawed assumption, and served no further purpose.

The inherent issue of rekeying the existing joysticks was that there were some 70,000-odd keys already across the various timelines, which would have taken months to transcribe one by one. All attempts to use Rive’s, as well as other, AI agents were largely fruitless. (Rive’s agent could move keys one by one, Claude for Chrome moved a few keys but was slower than a human!)

After a few days, I had a bit of a crackpot theory that I could remap the keys onto the normal joysticks if I could decode enough of how the .rev (Rive's backup file format) worked, and more importantly how it mapped to .riv (the open-source runtime level file format). I naively formed it from this comment:

.riv's are just binary snapshots of the .rev at a point in time (you do not edit the .riv).

—Guido Rosso, in the Rive for Unreal Engine Reddit thread

I'm not going to go into super granular details of parsing the .rev because a. it's a proprietary format (to my knowledge only used in the Rive editor itself) and chiefly b. I don't really want to, it was mainly a lot of guess work and comparing to .riv files until I was able to translate it into a pseudo-JSON format for my own sake.

At a high level, the .rev stores an object stream after an ART marker, followed by a record count, then a long flat sequence of typed records where each record has a numeric type key, some status information, an object ID, and finally a set of properties keyed by number. You need the runtime definitions to decode these (148 is a joystick, 299 is joystick x, property 300 is joystick y, etc). This was pretty tedious but not actually all that complicated. After about a month of fiddling I would say I had a decent grasp of how it all worked, as well as a semi-functional parser!

The existing animation chain looked something like this:

Keyframe Percent space Custom handle Component x/y Visual layer Extra abstraction Native joystick Runtime output

I needed to somehow collapse each key into this:

Keyframe Percent space (float normalized) Native joystick Joystick x/y

So, for the aforementioned 75,000+ numeric keyframe values, each needed it’s target changed, it’s property ID changed, and it’s value converted into a different coordinate space.

My first few (minimal) attempts at rewriting the file failed to succesfully upload to Rive’s editor. (Probably the most annoying part of this was not getting any debug messages, just a binary yes/no of “failed to upload”) I initially theorized it was an issue with my migration logic, which led me down a few weeks of dead ends. It was a much dumber oversight on my part: references between objects in a .rev aren’t plain integer ID’s, they carry a marker or status byte in front of the varint-encoded object ID. If you rewrite the ID but use the wrong marker byte you get a file the editor will reject based on the incorrectness of it’s full encoded references.

The Math!

Value Space Conversion

The custom handle controls were authored with values that were mimicking percentages. For example, a handle at position 50 on the x-axis represented 75% of the joystick’s range. Joystick x/y properties in the .rev (as well as at runtime) are stored as normalized floats, from -1 to 1. This meant that the conversion in principle would be simply dividing by 100. This worked for most of the joysticks, but not for all of them. I later found that 5 of the joysticks had 40 by 200 bounding boxes, which broke the conversion for them, but that was a simple fix. I was largely convinced this was the issue for a while, until realizing they weren’t actually keyed on any of the timelines.

Overshoot

The values that caused the visible breakage were outside the -100 to 100 range, because a lot of the authored handle positions went way past the nominal boundaries, with values like -232.5, 577.5, -300 and so on. The question was whether the migration should preserve those large values as-is (which would mean the joystick gets driven to 5.775 on its y axis) or clamp them (which would lose the authored overshoot). To solve this, I consulted the handy C++ runtime with a small helper program (also in C++) that loaded the .riv, advanced the animations in question to the overshot frames, and read the joystick x/y values. At the frames in question, it would be clamped to the bounds of -1,1. A handle value of 577.5% produced a joystick value of 1.0, meaning it got clamped somewhere before it drives the target animation. This meant the big overshoot values in my source data were being silently handled at runtime and I needed to replicate their clamping in my conversion.

1 -1 Raw overshoot Runtime clamp

Cubic Interpolation

Rive makes use of cubic Bezier interpolation for keyframe easing. The way it works, as far as I could deduce from the runtime, is that they have a standard cubic Bezier defined by four control points in a unit square, where the x-axis is time (normalized from 0-1 in the keyframe segment), with the y-axis being the interpolation factor. There’s also a second kind called CubicValueInterpolator where the y-axis of the control points is in the actual value space of the property being animated, in lieu of 0..1 normal space.

So, for a normal cubic ease, the control points could look something like (0,0), (0.42,0), (0.58,1), (1,1), which don’t change regardless of what the animated values are. However, for a cubic value interpolator the y-coordinates of the control points are actual joystick position values. Thus, if a keyframe goes from -100 to 200 with cubic value easing, the control handles might have y-values like -50 or 180 in handle-percent space instead of joystick space. My first few passes only converted the keyframe endpoint values and left the cubic control handles alone. This produced some comical files where the character hit the right poses at the right times but moved through ugly, jerky paths between those poses because the easing curve was still shaped for percent-space motion while the endpoints had been clamped. The best way I can describe it is imagining if someone was trying to speedrun a Just Dance game (obligatory I’m A Sheep reference)

The fix was to apply the same /100 + clamp conversion to the cubic value control handles, but this introduced a brand new problem.

Value handle

Clipping Geometry

Imagine you have a keyframe segment where the old handle value goes from -50 to -300 with a cubic ease that overshoots past -300 before settling. After converting to joystick space, the endpoints become -0.5 and -1.0 (because -300 clamps to -1), but the cubic control handles have been scaled down too, so the easing curve between -0.5 and -1.0 is now a gentle smooth curve, when in reality the original motion was supposed to hit -1.0 and then stay there because the runtime was clamping everything past the boundary.

The old evaluation chain would evaluate the cubic curve at the current time, get a handle value (which might be -250 at some point during the ease), then clamp that to joystick range, so the actual joystick value would be -1.0 for a big chunk of the segment. My conversion evaluated the cubic curve, scaled the result, and clamped the endpoints, so the joystick value would smoothly interpolate from -0.5 to -1.0 along the entire segment without the flat clamped region in the middle.

To measure how bad this was, I built a curve sampler that evaluated both the old version and the "migrated curve" version at hundreds of sample points per keyframe segment, across the animated joystick axis’s in their timelines. The initial results showed a maximum in-between error of about 0.99 joystick units, which is enormous, practically the full range of the joystick! This was happening at the peaks of overshoot curves that should have been clamped flat.

Missing flat region Clamped output Endpoint-clamped curve

Curve Splitting

My approach to reduce this error was to find every keyframe segment where the old curve had crossed our float boundary, figure out the time (in frames) that crossing happened, and insert a new boundary keyframe at that frame with the value clamped to exactly -1 or 1. This effectively split the original segment into a part that stays inside the joysticks range (which can use a normal converted cubic) and a constant sitting flat at the boundary.

insert boundary key clamp boundary inside range flat clipped segment

The trickiest part here was that animation frames are integers, so if the curve crosses the boundary at hypothetical frame 157.4, I could only insert a key at frame 157 or 158. In either case, there would be a one frame window where the migrated curve isn’t perfectly matching the behavior of the old system. For those one frame crossings I used a small optimizer that fit a cubic Bezier to the clamped shape to minimize the maximum deviation from the old curve.

After the above splitting and fitting passes, the sampler showed a maximum curve error of ~ .053 joystick units (down from .99) and a mean error across roughly 2.5 million sampled points of ~ 0.000028. Rive’s actual runtime solver does a lookup table pass followed by Newton iterations followed by subdivision as a fallback, so I was extremely confident at this point the comparison was for all intents and purposes apples to apples.

In Conclusion

After some initial head-bashing, the .rev format was surprisingly iteratable, which has left me with immense optimism for a potential way of introducing extensible scripts to the Rive editor. Oh wait!

Guido Rosso Discord message about edit time scripts in Rive.

I don’t know if anyone else has done something like this to a .rev file, I couldn’t find much evidence of it when looking and asking around. For the sanity of Rive-rs worldwide, I hope I’m the first and the last! (Due to edit-time scripts)