Local high scores¶
The port keeps every local score in one file, scores.jsonl in the data
folder, which the Python game and the native and web clients share. The
original's per-table scores5/*.hi files import into it.
scores.jsonl¶
One JSON object per line, appended when a run is saved:
{"v":1,"name":"banteg","game_mode_id":1,"run_elapsed_ms":754321,"score_xp":98765,"most_used_weapon_id":3,"shots_fired":900,"shots_hit":600,"creature_kill_count":410,"uni_num":12345,"date":"2026-10-22","replay_number":17,"run":"<sha256>","rules":2,"game_version":"0.21.0"}
v is the record version. A field at its default is left out: no quest, not
hardcore, one player, the default list, 1.9.93's rules, zero counters, no
replay. A reader skips a line it cannot decode, such as one a crash left half
written, and a writer starts a new line after it.
The v1 readers use the same strict contract. v is required and must be the
integer 1; unknown or duplicate fields reject the line. Integer fields take
integer JSON tokens (not 1.0, strings or booleans), with the signed/unsigned
32-bit bounds below. Modes must be known, weapons are 0..53, and player count
is 1..4. A quest level is null or {major: 1..5, minor: 1..10}, and only quest
scores may carry one. A build is 10908 or 10993; writers leave 1.9.93's
out, so a reader from before builds, which rejects the field, still takes
every 1.9.93 line and skips only 1.9.8's. Booleans must be JSON booleans. Dates are null or valid
YYYY-MM-DD dates in 2000..2255, the original's representable calendar.
Names and named lists are at most 31 Latin-1 characters; build strings also
use Latin-1. NUL is rejected, and strings use valid UTF-8 on disk. A run id is
empty or 64 lowercase hexadecimal digits; rules are null or an unsigned
32-bit integer. received: true is never a local record. Missing fields take
their documented defaults. tests/persistence/score_contract.json exercises
both production decoders, including malformed and boundary values.
| Field | Holds |
|---|---|
name |
The name typed at the results screen, trailing spaces trimmed down to the first character, as highscore_save_record does. |
game_mode_id, quest_level, hardcore, player_count, score_list, cl_build |
The table the record belongs to; cl_build is 10908 for a run on the 1.9.8 rules. |
run_elapsed_ms |
Game time in ms, signed; a quest stores its final time, which bonuses can make negative. |
score_xp |
XP, or kills in Typ-o. |
most_used_weapon_id, shots_fired, shots_hit, creature_kill_count |
The score card's stats. |
uni_num |
The random tag highscore_record_init draws. |
date |
The local date the record was saved. |
replay_number |
The run's replay, replays/<n>-<mode>.crd (replay format); none for a record imported from before replays were numbered. A number whose file is missing is a replay that was not saved. |
run |
The run's leaderboard id, the SHA-256 of its replay payload. |
rules, game_version |
The replay's rules and build. |
import_id |
Empty for a new run; for an imported row, the source digest followed by : and its decimal byte offset. |
A table is a mode, its player count, the selected named list and the build
whose rules its runs played, as 1.9.8 kept its scores in scores4 apart from
1.9.93's scores5; each quest has a table per difficulty. Survival, Rush and
Typ-o keep hardcore runs in their one table, as the original does. Typ-o's
table also gives Typ-o its high-score target names. Every game shows the
tables of the build the Play Game menu's 1.9.8 box picks, and saves a run in
its own build's; scores5 imports into 1.9.93's.
Survival and Typ-o rank by score descending, Rush by time descending, quests
by time ascending with zero last; both fields compare as signed 32-bit values,
as the native comparators do. Equal scores keep their saved order. The
month, week and day filters use the local calendar, with the native
dateWeek week rather than an ISO week, and run before the 100-row display
limit. Saving a qualifying score keeps every record outside the shown table.
Records received from the leaderboard are never saved. While Show internet
scores is on, a local record the board holds turns green: it matches by
run, or, for an imported record without one, by name, time and XP.
Writers hold the score file's exclusive lock while repairing its tail and
completing partial writes, then flush it before acknowledging success. Python
uses flock on macOS/Linux and a byte-range lock on Windows; the desktop host
takes the same flock. The browser already owns its directory with a Web Lock
and persists it through its existing IndexedDB synchronization. A failed append
rolls back its bytes while holding the lock, so retrying a save does not keep a
partially completed batch.
Import from scores5¶
At startup each scores5/*.hi* file's records are appended to
scores.jsonl, and the file moves to scores5/imported/ (with .1, .2…
when that name is taken). A table an older client writes later imports on the
next start. The file name gives the player count and the named list; the
record gives the mode, quest and difficulty. Records the original received
from the internet stay out.
An imported row's source digest is SHA-256 of the original file name in UTF-8,
a NUL byte, and the complete source file bytes. Its offset counts from the
start of that file, including invalid and internet records. Readers keep the
first valid row for each nonempty import_id, so an interrupted or concurrent
import can append again without duplicating visible scores. Identical rows at
different offsets remain distinct. Failed writes leave the source in place;
archives retain invalid rows for recovery. Invalid enum/calendar fields are
skipped, and non-quest rows discard the original's stale quest-stage bytes.
The original's format¶
Each file is a sequence of independently encoded records with no header: a
0x48-byte payload followed by a little-endian u32 checksum.
For byte index i, encoding adds (i * 5 + 1) * i + 6, modulo 256. The
checksum is the sum of (i + 3) * decoded_byte * 7, modulo 2³², with each
byte read as a signed char (0xFF counts as -1). Readers skip invalid
checksums and ignore an incomplete trailing record.
| Offset | Field |
|---|---|
0x00 |
Name, 0x20 bytes, NUL-terminated Latin-1. |
0x20 |
Time, signed ms. |
0x24 |
Score. |
0x28–0x2B |
Mode, quest major and minor, most-used weapon. |
0x2C–0x38 |
Shots fired, shots hit, kills, random tag. |
0x3C |
Reserved: never read, left out of the duplicate check. The port stored the replay number here. |
0x40–0x43 |
Day, native dateWeek, month, year minus 2000. |
0x44 |
Flags: bit 0 received from the internet, bit 1 of local origin. |
0x45 |
0x75 for a hardcore quest. |
0x46–0x47 |
0x7C 0xFF. |
Normal quest files use questhcM_N.hi; hardcore uses questM_N.hi, an
apparent inversion that is native behavior. Survival and Rush use
survival.hi and rush.hi. The original has no Typ-o name:
highscore_build_path falls through to scores5\unknown.hi, relative to the
working directory rather than the game folder. An earlier port saved Typ-o's
table as typo.hi. Two-player tables add _2 before .hi, and the port
added _3 and _4; a named list appends its name after .hi.
Implementation: src/crimson/persistence/highscores.py and
src/crimson/persistence/scores5.py; the web and native clients' are in
crimson-core/host/scores.inc, which writes the same lines. The native behavior is in the recovered
sources under decomp/1.9/crimsonland/highscore/ (highscore_save_record.cpp,
highscore_load_table.cpp, and the highscore_compare_* comparators).