Created Monday, May 6, 2024
In another offshoot of my Mario Maker 2 Datasets project, which was itself an offshoot of the Mario Maker 2 API, I have been using the ninji data I scraped to render visualizations of each speedrun and the performance of the players. This blogpost is about the first part of that exploration: rendering speedruns as a trajectory visualization.
As an initial start I counted the number of replays for each ninji event, each one obtained from the dataset and by using a call to https://tgrcode.com/mm2/ninji_info. The numbers are as follows:
Number of players in each Ninji event
In order to render the ninji replays as they were shown in game I needed to reverse engineer the binary sent to the client. One initial question I had was whether this binary contained inputs, which would be played back by the game with accurate physics for every replay rendered, or positions, which would simply render the replay in a specific position on each frame.
As a base I started with Kinnay’s reverse engineering work. This made it clear that ninji replays contained positions. (notably, there is a place in SMM2 where input replays are recorded, I’ll be making a blogpost about that in the future). With some further research and testing I modified the format to the following:
Offset | Size | Description |
---|---|---|
0x0 | 0x3C | File header |
0x3C | 0x249FA | Replay frames |
Big endian.
Offset | Size | Description |
---|---|---|
0x0 | 4 | Version number (always 2) |
0x4 | 4 | Unknown |
0x8 | 4 | Unknown (usually 64) |
0xC | 4 | Time in milliseconds |
0x10 | 4 | Number of frames |
0x14 | 1 | Character |
0x15 | 1 | Unknown (usually 1) |
0x16 | 2 | Unknown (usually 4) |
0x18 | 4 | Unknown |
0x1C | 4 | Unknown (maybe points?) |
0x20 | 4 | Unknown (usually 0) |
0x24 | 4 | Unknown (usually 0) |
0x28 | 4 | Unknown (usually 0) |
0x2C | 4 | Unknown (usually 0) |
0x30 | 4 | Unknown (usually 0) |
0x34 | 4 | Unknown (usually 0) |
0x38 | 4 | Magic number (always SPGD ) |
Corresponds to every 4 frames. Little endian with no padding.
Offset | Size | Description |
---|---|---|
0x0 | 1 | 0xAB A : FlagsB : Player state |
0x1 | 2 | X position (tiles are 16x16, centered on tile) |
0x3 | 2 | Y position (tiles are 16x16, centered on tile) |
If flags & 6
:
Offset | Size | Description |
---|---|---|
0x5 | 1 | Unk1 |
If Unk1 & 24
:
Offset | Size | Description |
---|---|---|
0x6 | 2 | Unk2 |
A bitmask.
Value | Description |
---|---|
x & 0100 | In pipe transition |
x & 1000 | In subworld |
Value | Description |
---|---|
0 | Mario |
1 | Luigi |
2 | Toad |
3 | Toadette |
Value | Description |
---|---|
0 | Run |
1 | Jump |
2 | Swim |
3 | Ground pound |
5 | Slide down slope |
7 | Dry bones shell |
8 | Clown car |
9 | Cloud |
10 | Boot |
11 | Run |
Value | Description |
---|---|
4 | Link suit downward sword |
5 | Crouch |
Value | Description |
---|---|
10 | Yoshi |
12 | Cape feather glide |
13 | P balloon moving |
14 | P balloon stationary |
Value | Description |
---|---|
4 | Slide down slope |
6 | Slide down wall |
10 | Yoshi |
12 | Acorn suit glide |
13 | Propeller suit fly |
14 | Propeller suit glide |
Value | Description |
---|---|
4 | Slide down slope |
6 | Slide down wall |
7 | In pipe |
8 | Cat suit dive |
12 | Bullet bill mask |
13 | Propeller box fly |
14 | Propeller box glide |
Next I started writing code to parse the replays and render them. I used C++ and Skia, as the number of replays, each with possibly 300 or more replay frames, would strain any other rendering library. Skia was designed to render webpages in Chrome by Google, so it’s extremely fast.
A good visualization is one that can encompass the knowledge of the entire event in one picture. That is how I came across a trajectory visualization, which is often used in movement data.
I applied a exponential decay curve to the colors of each replay, as it is guaranteed to be 0
at 0
and 1
at 1
. This allowed me to use a color for the fastest time, which is lime green, and the slowest, which is red. The top 10 runs were colored aqua blue to set them apart.
The equation is as follows:
\(HSV = \left(15 + (1 - p)^{\frac{1}{d} - 1} \times 0.95, 1, 0.75\right)\)
Where p is linearly 0
for the fastest time and 1
for the slowest. d
is a chosen value, tuned to show the differences in times best.
For example, for "The Speedventure of Link" the color curve looks like this:
Desmos color curve
This curve highly punishes even remotely slow players. This is because in practice there will always be players who are as slow as possible, thus the players that represent those attempting to improve their runs significantly usually constitute only the top 10% of runs.
Next, I started rendering lines. Because ninji replay frames happen only ever 4 render frames, or around every 66 milliseconds, the resulting replays look rather polygonal when rendered point to point. Because of the lack of information present in the replay this is an unavoidable fact. I chose not to render the replays with splines and instead just rendered pixel-perfect lines between the positions.
Click to zoom into each visualization!
Rolling Snowballs | The Speedventure of Link |
---|---|
The 10-Coin of Deep Woods | Cat Mario Dash |
Banzai Bill Cliff Climb | Swinging Claw Flyway |
Headgear Hustle | Balloon Race |
Yoshi’s Piranha Plant Picnic | Player’s Choice: Power-Up Party |
Big Shoes Gustin' in the Desert | Squirrely Airship Escapades |
At the Croak of Midnight | 35th Anniversary Auto-Mario |
Cannon Box Blast! | Goombud Bust-Up |
SMB2 Mario: Can You Dig It? | Dry Bones Shellscape |
Cape Mario Master | Bowser’s Castle: The Last Dash |
Link’s Lightweight Longshots |
Use the Contact button to the side or join my Discord.