an ok speedrun timer for linux
make perf feature opt-in
log::info! rather than println!
README (again)


browse  log 



You can also use your local clone with git send-email.


cleave is an ok speedrun timer.


video demo


  • Linux support. It might work on other platforms, or it might not.

  • Pretty colors.

  • "Chapter" support: Splits can be further split into subsplits.

  • Configurable global keyboard bindings.

  • You can bind functions to the same controller that a game is using, so you never have to move your hand to the keyboard. For example you can bind Start/split/save to the "Guide" button (Fancy X on xbox and Fancy P on playstation), which isn't used by any game I'm aware of.

  • Dynamically resizable to a limited extent.

  • Can save run times for partial runs and resume later (intended for long segmented runs, practice runs, and the like).

  • Tracks statistics such as best overall time, best segment times, and does (quite dumb) estimates for the current run.

  • Stable 60 FPS display refresh rate. [*]

  • Runs are saved in a sqlite database, which is easy to backup and hopefully hard to corrupt. You can read this database to do your own analysis (attempts per day, PB over time, total run time, …), generate a description with timestamps for your favourite video hosting platform, convert to a format for a better speedrun timer, …

  • The database is also used for all configuration. As a result, it's possible to use different colors/fonts/keybindings for different games.

  • No java or dotnet.

  • Reasonably small footprint and decent performance: Even with 10k completed runs of a route with 100 subsplits, the database weighs less than 20MB and cleave starts in less than a second. For more likely scenarios (100 runs or fewer), the database weighs less than 1MB and cleave starts "instantly". With fewer subsplits, the values will be even better.

[*] Refresh rate is automatically adjusted to the monitor's refresh rate via VSync. Higher refresh rates will likely work perfectly, but are untested. The internal timer is independent from the display refresh rate. In a real-world situation on the author's machine, the timer achieved a mean update time of 0.43 ms per frame (includes the time to handle events and draw to the screen, excludes vsync wait time) with an upper 99.9% quantile time of ~2.5 ms, with data collected over a 10 minute time interval. With VSync disabled, update time drops to 0.13 ms per frame with a 99.9% quantile of ~1.1 ms, at the cost of hogging a full CPU core and the possibility of screen tearing.

#Current status

Version 1 is done and development is on halt. There's some inconsistent terminology and setting up a database for a new category is rather annoying, but once configured, it is very usable. Only minor changes (if any) will be made, and only as I encounter issues.

Version 2 will have a new graph based data model for routes. New databases will be incompatible with older ones, but a conversion script from v1 to v2 will likely be provided when (if) version 2 is finished. Conversion scripts for databases made on v2 dev builds will likely not. Version 2 will also focus on improving database setup/configuration and will probably include a second GUI for configuration and statistics.

#Functional limitations

cleave is currently subject to the following limitations which may or may not be removed in the future (but don't expect any changes soon):

  • Keybindings configuration is a pain because only X11 keycodes are supported. You have to use a different program (e.g. xinput-keycode-logger) to find out which key on your keyboard corresponds to which keycode. On the positive side, it works independently from the keyboard layout, and if you know the keys, editing the database is scriptable (see utils/config.sql for an example).

  • No wayland support: XInput2 is required for global keybindings.

  • No GUI configuration editor.

  • No support for devices that don't act like a keyboard or controller (e.g. foot pedal).

  • Fonts cannot be specified by name. Instead, the path to the file must be given instead.

  • Visual layout configurability is rather limited (and undocumented).

  • No support for associated data such as a hit counter or notes

The following limitations will likely remain in place. If you need any of these features then please look elsewhere, fork this project, or make your own.

  • No integration with other programs, e.g. for autosplitting, in-game time, notes, …

  • No cross-platform support. I don't go out of my way to break compatibility, but my only target is Linux+Xorg and I'm not testing anything else.

  • No pretty pictures/sounds.

  • No online sync to get WR splits via online API, for races, etc.

  • No internationalisation.

  • No surrogates for missing glyphs; you have to use a font that contains all glyphs you need.

  • You can't run two timers on the same database at the same time. What would be the point anyway?

#How to install/run

Download and build the software:

$ git clone https://git.sr.ht/~quf/cleave
$ cd cleave
$ cargo build --release

Initialize a run database for a new game/category:

$ cat > segments.txt << EOF
Chapter 1
  Acquire the thing
  Do the thing
Chapter 2
  Defeat the guy
  Clip through the wall
  Walk to the place
Chapter 3
  Prepare for the end
  The end
$ cargo run --release init --title "Emberforce 3" --category "any% no WW" --splitlist segments.txt times.db
$ sqlite3 times.db < util/config.sql # configure fonts, keybindings, etc. make sure to edit this to your needs!

Run the timer:

$ cargo run --release run times.db

sqlite3 (version 3.37+) and SDL2 (including SDL2-ttf) needs to be installed.



#Can I safely publish the debug output?

When run at debug log level "trace" (the most verbose setting, not standard), cleave will act as a keylogger! DO NOT PUBLISH THE DEBUG OUTPUT unless the log level is higher than "trace" or you're absolutely sure you haven't typed any secrets (passwords, chat messages, …) while cleave was running!

When run at debug log level "debug" (not standard), cleave will log all key and button presses that it reacted to, but not others.

#How do I create a database for a new game/category?

$ cargo init --title "Nifty game" --category "All achievements wrong warp" --splitlist splits.txt niftygame.db

where splits.txt contains a list of splits and subsplits in order like the following:

  Part 0.1
  Part 0.2
Chapter 1
  Chapter 1
Chapter 2
  Acquire the thing
  Use the thing

#How do I change/add a route (splits/subsplits)?

First, make a backup. Open the database file with sqlite3 and execute the following steps.

Enable foreign key constraints and start a transaction:

sqlite> pragma foreign_keys = on;
sqlite> begin transaction;

Query the database to get an unused route number:

sqlite> select seq+1 from sqlite_sequence where name='route';

In the following, I will assume that our new unused route number is 42, so replace that value with the actual one.

Next, add a new route with that number:

sqlite> insert into run values (42);

Add your splits:

sqlite> insert into split (route, id, name) values (42, 0, "Prologue");
sqlite> insert into split (route, id, name) values (42, 1, "Chapter 1");
sqlite> insert into split (route, id, name) values (42, 2, "Chapter 2");

and so on.

Add your subsplits:

sqlite> insert into subsplit (route, split, id, name) values (42, 0, 0, "Prologue Part 1");
sqlite> insert into subsplit (route, split, id, name) values (42, 0, 1, "Prologue End");
sqlite> insert into subsplit (route, split, id, name) values (42, 1, 0, "Chapter 1 part 1");

and so on.

Stop the current paused run if one exists, and make future runs follow the new route:

sqlite> delete from current_run;
sqlite> insert or replace into current_route (rowid, id) values (0, 42);

If you're sure you did everything correctly, commit the transaction (saving the changes in the database) and quit:

sqlite> commit;
sqlite> .quit

#I made a typo and need to change the title

sqlite> insert or replace into title (rowid, title) values (0, "cool game");

#I made a typo and need to change the category

sqlite> insert or replace into title (rowid, title) values (0, "any% NMG");

#These colours are ugly.

Open the database with sqlite3 and execute:

sqlite> insert or replace into colors (key, r, g, b) values ("background", 122, 89, 1);
sqlite> .quit

Special keys are:

  • "background"
  • "highlighted background"
  • "foreground"
  • "best"
  • "very good"
  • "bad"
  • "very bad"

r, g, b, are red, green, and blue color channels with 8 bits per channel (i.e. valid values are 0-255 inclusive).

#I hate the font.

Open the database with sqlite3 and execute:

sqlite> insert or replace into fonts (key, filename, size, style) values ("main timer", "my_cool_font.ttf", 50, "bold");
sqlite> .quit

The "fallback" key is special and designates a font that is used in place of any unknown/invalid font. If it is not specified, a built-in font will be used.

Valid font styles are:

  • NULL
  • "normal"
  • "bold"
  • "italic"
  • "bolditalic"

Note that you need to insert the actual file name (relative or absolute path), not just a font name.

Also note that the timer fonts should have monospaced digits. They don't need to be monospaced fonts all around, but if the digits vary in width, you'll get a headache watching the timer.

#I only changed one split and now my estimates and sum of best are missing/wrong!

The data are still there. However, routes are all independent as far as cleave is concerned and only split times from runs with the current route are considered for estimates and best splits. If you switch back to a previous route, the best splits from all runs with that route will be used.

#How do I switch to a previous route?

First, make a backup. Open the database with sqlite3 and execute the following:

sqlite> insert or replace into current_route (rowid, id) values (0, /* route number goes here */);
sqlite> .quit

#How do I find out the right number for a previous route?

You'll have to do some detective work.

To see all route numbers, use

sqlite> select id from route;

To see the splits/subsplits for a certain run, try

sqlite> select id, name from split where route = /* route number goes here */ order by id;
sqlite> select id, name from subsplit where route = /* route number goes here */ order by (split, id); /* all subsplits for a route */
sqlite> select id, name from subsplit where route = /* route number goes here */ and split = 1 order by id; /* all subsplits for a split */

#How do I delete a route?

Don't, just switch to a new one.

If you insist, you'll first have to remove all runs for that route, then all subsplits for that route, all splits for that route, and then the entry in the route table.

#How do I delete a run?

Don't, just update the incorrect metadata/splits.

For example if you saved a run you didn't mean to, then delete the incorrect splits as well as the final time (if it was saved as a complete run). To delete the splits, refer to the section below; to delete the final time (so the run doesn't count as completed), find out the run number (for example 123), make a backup (!) and execute the following:

sqlite> update run set time = null where number = /* run number goes here */;
sqlite> .quit

#My estimates/sum of best are wrong because I split too early/late, how do I fix it?

First, make a backup.

Next, find out the run and (split, subsplit) id. Let's say you split at the wrong time between subsplit A and B, where A has split and subsplit ids (3, 6). The run id is 123 and the route number is 0.

Open the run database with sqlite3 and execute the following:

sqlite> delete from time where run = /* run number goes here */ and split = /* split number goes here */ and subsplit = /* subsplit number goes here */;

#How do I find out the id for a certain run?

Do some SQL detective work. For example:

sqlite> select * from run
...   > where
...   > is_rta = 0
...   > and start between date('now', '-2 month') and date('now', '-1 month')
...   > and time_ns < (1000*1000*1000*60*23);

#My PB is fake because I accidentally saved a run too early, how do I fix it?

First, make a backup. Next, find out the run and (split, subsplit) id. Open the database with sqlite3, enable foreign key constraints and start a transaction

sqlite> pragma foreign keys on;
sqlite> begin transaction;

Then delete the times for the incorrect splits (see above), or even all splits like this:

sqlite> delete from time where run = /* run number goes here */;

Also set the total time to NULL so the run isn't counted as complete:

sqlite> update run set time_ns = null where number = /* run number goes here */;

Finally commit the transaction and quit:

sqlite> commit;
sqlite> .quit

#Do I really have to learn SQL to configure this?

Kind of, yes. The basics aren't that hard though.

Refer to the util/ subdirectory for sample config scripts using SQL and more elaborate evaluation scripts written in Python.

#How do I import my previous runs from X program/format?

If X is flitter, then cargo run --features flitter --bin import_from_flitter my_runs.scm.

Otherwise, you're on your own.

#How do I export my run database to X program/format?

To get the data out of the database, take a look at src/db.rs, specifically the init_tables function. As for getting the data into whichever form you want, you're on your own.

#How is the data stored in the database?

Check out init_tables in src/db.rs for an overview with table schemata.

#How do I change the layout?

Layout definitions are mostly undocumented. Some samples are provided in the layouts subfolder. For a full list of available widgets, you have to look at src/subcommand/run/draw.rs, specifically the build_widget_tree_from_list function.

To initialize a database and specify a layout at the same time, use the --layout flag for the init subcommand. The layouts definitions are saved in the database in the layout table. The layout definition under key current will be loaded, if one exists.

To load a layout definition from outside the database, use the --layout flag of the run subcommand. This option overrides any layout definitions in the database.

#Have all of these really been frequently asked?

Some of them have (mostly by me).