A few months ago, I was presented with the task of converting some custom visual joysticks in the character rig to Rive’s native joysticks layer. It was suspected 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.revat a point in time (you do not edit the.riv).
I quickly realized how wrong I was that this would be the “simpler” way of attacking this task. Opening a .rev in a text editor gives you something that looks like this. (We have to deal with the .rev because it needs to be editable in Rive)
In our case, this went on for about 13MB! Luckily, I was able to get that number down to 8MB by removing some redundant assets from the assets panel, and disregarding the other components we had in our file.
I'm not going to go into the super granular details of parsing the .rev because 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. I’ve attached a screencap of just one day’s worth of .rev files while experimenting!
What I’ve figured out is that 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! (Heaven forbid anyone need to edit a .rev in the way I did, reach out to me via Email or X and I’ll be more than happy to give you a fuller breakdown)
The existing animation chain looked something like this:
I needed to somehow collapse each key into this:
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.
Most of my initial attempts at rewriting the file failed to successfully 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 the editor will reject the file based on the 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 their conversion, 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. The actual issue was far more complex.
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 overshoot values in my source data were being handled at runtime and that I needed to replicate their clamping somehow in my conversion.
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 (and the GUI in the editor!), 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 pretty comical files where the character hit the right poses at the right times but moved through ridiculously 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 introduces a brand new problem!
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 extreme peaks of overshoot curves that should have been clamped flat.
Curve Splitting
My approach to reduce this error was to locate 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.
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 turned out to be far more iterable than I expected. It leaves me pretty optimistic about a potential plugin system or way to introduce scripts in the editor. Oh wait!
I asked around and couldn't find anyone else who'd attempted similar things with .rev’s, and I hope for everyone's sake that edit-time scripts arrive long before anyone else needs to. Happy Rive-ing!