Repository navigation
Save Format
Reverse engineered from sinking_star.exe (Jai, Thekla). All values are little-endian. The game keeps its
saves in %USERPROFILE%\Saved Games\Order of the Sinking Star\:
| File | Writer (IDA name) | Format |
|---|---|---|
settings.menu_state |
save_menu_state |
menu settings, version 9 |
Unsynced_Settings.menu_state |
none in the current build | menu settings, version 4 (stale file from an older build) |
<campaign>/Status.sokoban_status |
save_campaign_status |
campaign status, version 33 |
<campaign>/<level>.history |
save_level_history |
per-level undo state, version 73 (loader accepts 70, 72, 73) |
Saves go through a save thread (campaign_save_thread_proc) that writes _temp_<name> and atomically
replaces the target. A .history file is deleted when the level is won (File_Save_Reason.VICTORY).
Notation: str16 = u16 length + UTF-8 bytes, str64 = s64 length + bytes. Pid = u32 entity id,
namespace << 20 | serial (namespace 0 = global, 1023 = runtime-spawned, otherwise per level), shown as #ns-serial.
Times called Apollo time are Jai Apollo_Time: a signed 128-bit count of femtoseconds, stored as two s64
(low, high). Wall-clock: FILETIME = (apollo - ((-630480 << 64) + 0xAE1758526CFC8000)) / 10^8.
u32 version, then fields added over versions (reader load_menu_state gates on the version):
| Since | Type | Field |
|---|---|---|
| 1 | f32, f32 | music volume, sfx volume (0..1) |
| 1 | s8 | render resolution (Render_Resolution) |
| 3 | u8 | graphics mode (Graphics_Mode) |
| 4 | u8, u8 | fullscreen, window maximized |
| 4 | 4 × s32 | window rect |
| 5 | f32, f32 | voice volume, overall volume |
| 5 | u16, u8 | text language (Language), language explicitly chosen |
| 6 | u8, u8, s64, u8 | subtitles, vsync, target frame rate (0 = unlimited), unknown flag |
| 7 | s64, u32 | vsync interval divisor, unknown |
| 8 | s64 | MSAA mode (Msaa_Mode) |
| 9 | u16, u8 | voice language, voice language explicitly chosen |
| Type | Field |
|---|---|
| u32 | version (33; reader accepts 5..33) |
| u32 | reserved, 0 (present since v30) |
| s64 × 2 | last saved, Apollo time |
| u8 | rank (Character_Rank 0..5: Visitor, Egressant, Pusher, Arranger, Intrepid, Sokoban); only increases |
| s64 × 2 | play time, Apollo time duration |
| s64 | achievement bitmask, bit n = Achievement value n (bit 0 is also set) |
| u32 | Skipping Stones treasures, bit = Treasure.treasure_index (6 treasures; overworld displays use the popcount) |
| 10 × u32 | gameplay counters (see RULES.md), only written and saved |
| s64 | artifacts, bit = Artifact.kind, only kinds 3-6 (unobtainable in this build; always 0) |
| u32 | current area pin (Pid of the Area_Pin whose local_area box holds the hero; recomputed per frame, 0 inside levels) |
| s32 × 3 | levels solved, tower levels solved, towers activated (refreshed on overworld passes) |
| u8, u8 | overhead-map area-names toggle, overworld reached (switches the menu between "lobby" and "overworld") |
| (v23..31 only) u8 + 32 × u8 | legacy data |
| str16 | current level |
| str16 | level shown behind the main menu |
u32 n, n × (str16 level, u8 Level_State_Flags) |
per-level state: SOLVED 1, PARTIALLY_SOLVED 2, ENTERED 4 (never set), TOWER_UNLOCK_SIGNALLED 8. A level gets an entry (flags 0) when it is first loaded |
| u32 n, n × (str16, s64, str16) | string table, legacy (only loaded and saved) |
| u32 n, n × Pid | opened ice gates (Freezer) on the overworld |
| u32 n, n × Pid | opened ice gates in other levels |
Which levels unlock what, and what counts toward towers and 100%, is in RULES.md.
u32 version writer 73; loader accepts 70, 72, 73
u32 Settings.level_data_version mismatch => "World state has been reset, because we changed this level."
s64 BASE_ENTITY_VERSION 25
s64 n; n × { str64 entity type name; s64 type version } type index used below = position in this table
u32 unknown (0)
s64 next move index
s64 move counter including undone moves
u32 0
u8 n (8); n × undo mip level
undo record the current, not yet committed record
u32 active hero Pid
u32 next pid serial, namespace 0
u32 next pid serial, namespace 1023
u32 next pid serial, level namespace
Undo mip level — level 0 has one record per move, level k condenses older history (each record spans
25^k moves, see debug_dump_undo_miplevels):
u16 factor (1 for level 0, 25 above) u32 capacity (400)
s32 uncondensed count s32 index_in_next_that_lines_up (-1 = none)
s64 n; n × undo record
Undo record (bw_put_undo_record):
s64 first move index u32 move span u32 active hero Pid
u16 flags (bit 0 = "!" in the debug dump) u8, u8 unknown
f64 time (seconds into the level) f32 unknown (0.6)
s32 × 3 hero grid position after the move
u32 n; n × Move_Transaction (metadata-serialized, see below)
s8 n (< 128; max 8); n × lily movement group { f32; u8; u32 c, c × 12 bytes; u32 c, c × 16 bytes }
u32 n; n × action { u8 Action_Type; ... }
| Action_Type | Payload |
|---|---|
| 1 CHANGE | Pid, s64 type index, str64 entity diff blob (old and new values) |
| 2 CREATION_OR_DESTRUCTION | Pid, s64 type index, str64 entity diff blob (new values vs. the type's defaults), u8 created |
| 3 OCEAN_CHANGE | Pid, s32 index, u8 old, u8 new (10 bytes with the type) |
| 4 SCRAPE_CHANGE | s16 x, y, z, u8 old, u8 new, u8 axis |
u16 base version (BASE_ENTITY_VERSION) u16 entity type version
repeat until end:
u16 field: index & 0xFFF, bit 0x1000 = field of the entity type, clear = field of Undoable_Entity_Data
[old value] (CHANGE only)
new value
u8 0xFD
Field indices number every non-constant member of the struct in declaration order, flattening nested
structs into their leaf members (position.x, position.y, ...) and skipping #NoSerialize members and the
using entity: Entity base. Members carry version notes (@v14, @v1-24); an index list for a given type
version contains only the members whose first version is <= that version, which keeps old files readable.
Value encoding (write_entity_field_diff): plain data (ints, floats, bools, enums, fixed arrays) as raw
bytes of the member size; strings and resource pointers (*Triangle_Mesh, *Material, *Texture, by name)
as str64; dynamic arrays as s64 count + elements.
data/schema.json holds the index lists for Undoable_Entity_Data and all 88 entity types, generated from the
Jai type info in the binary by scripts/ida/export_schema.py.
binary_serialize_with_metadata: u16 struct VERSION constant, then each serializable leaf member whose
version range contains that version: plain data raw, dynamic arrays as s64 count + elements (struct elements
recurse, each with its own u16 version). Version notes on a parent struct are not inherited by its flattened
members, so deprecated nested members (XXX_deprecated_visual_error, XXX_deprecated_charm_setup) are
still written.
-
Move_Transaction(v3): f64 close_time, f64 commit_time, u32 transaction_id, f32 duration, f32 elapsed, u32 flags, s8 lily_transaction_group_index (v3+), s64 n × Single_Move. -
Single_Move(v8, 156 bytes + array): Pid entity_id, Pid manipulator_id,Move_Info(u8 move_type, Vector3 delta, start_position, target, f32 duration, Pid target_mobile_supporter_id, Pid supported_by_id, u32 flags, u16 teleport_values, u8 post_teleport_rectification, f32 theta_cancelled_when_pushed_rate, Vector3 distance_cancelled_when_pushed_rate, f32 time_cancelled_when_pushed), f32 duration_until_physical_move, Vector3 visual_start, visual_end, XXX_deprecated_visual_error, u64 flags, f32 theta_at_start, f32 turning_theta_target, Pid charm walking_support, u8 charm movement_rate, s64 n × 80-byte deprecated Move_Info.
Read by scripts/parse_levels.py. A simp package: a flat archive of 2015 members, data contiguous from 0x40 to the
table of contents, which runs to the end of the file.
| Offset | Type | Field |
|---|---|---|
| 0x00 | char[4] | simp |
| 0x04 | u32 | 1 |
| 0x10 | u64 | TOC offset |
| 0x40 | member data, back to back, no padding |
TOC (at the TOC offset):
| Offset | Type | Field |
|---|---|---|
| +0x00 | char[4], u32 |
toc!, 0 |
| +0x08 | s64 | member count |
| +0x40 | n × entry | u32 name length; name; u8 0; s64 size; s64 offset |
| Member | Count | Format |
|---|---|---|
data-common/<level>.entities |
974 | level entities, below |
data-common/<level>.level_manifest |
974 | text: [1] line, then ;;;;; textures and ;;;;; meshes sections, one resource name per line (<name>~<level> for per-level tint maps) |
data/level_sets/<set>.level_set |
66 | text: [1] version line, then one level name per line; # starts a comment (also after a name) and *directive lines (*choice, *glyphs, ...) are not levels; a level can be in several sets, 36 levels are in none |
data-pc/*.asset_manifest |
1 | binary, starts 03 00 00 00 (not decoded) |
char[4] 'enty'
u16 version 7
s64 BASE_ENTITY_VERSION 25
s64 n; n × { str64 entity type name; s64 type version } same table as in .history; all 88 types in every level
u32 entity count
count × { u32 Pid; u16 type index; u32 blob length; blob }
The blob is an entity diff blob without the two u16 version words (they come from the header and the type
table), in CHANGE form: every field carries the type's default as the old value and the level value as the new
value. A field missing from the blob holds the type default (position 0, scale 1, ...). Fields come in index order,
Undoable_Entity_Data fields first, then the type's own (bit 0x1000).
Pids are scoped to the level: the save's .history refers to them raw, but the same Pid appears in several
levels with unrelated types (84,387 Pids occur in more than one level, 30,202 of them with different types).
The namespace is not a level id: 80 namespaces occur, a level mixes many (the overworld mostly 101, 73, 80,
121, 135, 115) and one namespace spans hundreds of levels. #1021-1 is the Level_State in 449 levels.
Cross-entity references (unlocked_by_id, extension_of, target_id, map_label_marker_id, ...) resolve
inside the same level (226,296 of 226,322; all on the overworld).
Read by scripts/map_data.py for scripts/build_map.py (top-down map). Readers in sinking_star.exe:
load_level_paint_and_lightmap_data (vertex_paint.jai, lightmap_data.jai) and parse_compressed_mesh (render/mesh.jai).
Asset header (.compressed_mesh, .texture, .shader): u16, u16, u16, u16 header length, u32 total size
(header included), then an LZ4 block of total - header bytes; files whose size equals the total are stored
uncompressed. Meshes have a 12-byte header, textures 40, shaders 24.
Per-entity vertex paint, version 6, all fields u32:
'allp' version 6 entity count array count
array count × { mesh checksum; n; n × paint } arrays are shared by entities with identical paint
entity count × { Pid; array index }
A paint value is one vertex's RGBA8 (byte 0 = red). The array replaces the mesh's own vertex colours when its
checksum equals the mesh's checksum (all 102,774 overworld entries with a mesh match, with equal vertex counts).
The shaders read it as layer weights, not colour: red exposes layer B (blended2, ground_general: weight
clamp(r + h * (r + mask - 1)), h = blend_hardness, mask = the MASKS_tex channel), blue attenuates normals.
u32 LOD count (3) u32 vertex count u32 checksum u8, u8, u8 skinned, u8
f32 × 6 bounding box (min xyz, max xyz) f32, f32 u32 u32 triangle list count (nl)
vertex count × f32 × 3 positions (+ 8 bytes per vertex when skinned; skinned character meshes differ further)
vertex count × 20 bytes (normal, tangent, UV; not decoded)
vertex count × u32 vertex colour (default paint, same layout as all_paint_data)
LOD count × { nl × { u32 material index, u32 index count, u32 first index }
u32 index count, u32 byte size, u16 format (0 = u16, 1 = u32), indices }
nl × str16 material name
The index buffer of a LOD holds each list twice, the second copy with reversed winding (mirrored placements).
Positions are in metres in the entity's space: world = rotate(quaternion) · (scale × p) + position.
base.materials[i], when non-empty, overrides material i.
Header bytes 12..: u16 width, u16 height, u16 depth, u16 mips, u8 format, u8, u8, u8. Formats 0 = BC1 and 1 = BC3 are
decoded and checked visually, 5 = 4 bytes per pixel (RGBA8); 2 (8-byte blocks, masks) and 3 (16-byte blocks,
normal maps) fit BC4 and BC5, and 4 (16-byte blocks, light-probe arrays) is not decoded. Mips follow largest first, laid out like a D3D12 upload: each block
row padded to 256 bytes, each mip padded to 512.
A simp package of text materials: # material version 1, then key value lines ("string",
(x y z w), numbers, true/false, flag Name). parent "<name>" inherits every key the material does not set.
shader picks the shader; the albedo-related keys used by the map:
| Shader | Albedo |
|---|---|
blended2 |
lerp(DIFFR_A × tint_color_A × entity tint_color_a, DIFFR_B × tint_color_B × entity tint_color_b, paint weight); use_tint_mask limits the entity colour to tint_mask_tex; swap_entity_colors swaps a and b |
ground_general |
as blended2, but each layer is also multiplied by a world-space tint map tint_map_A/B (u = (x − tint_map_offset_X) / tint_map_size_XY, v = (tint_map_offset_Y − y) / size, after rotating x, y by tint_map_rotation × 90°; tint_align_mode 1 and 2 use fixed 24 m and 84.7 m maps); the entity colour applies only with use_tint_colors_A/B (matched by name to material flag bits 128 and 512 in the shader, not checked on the CPU side) |
basic, foliage*, rocks_general
|
DIFFR_tex × tint, alpha = opacity in the masked variants |
crystal_general |
color_core / fracture_color (the red goal rooms) |
Entity colours reach the shader as half floats (Entity_Params.entity_color, effect_color). Water is
Ocean_Map: grid.corner relative to the position, grid.squares_x/y, ocean_values[i + j × squares_x] (i, j =
local square from the corner, the map wraps with the level grid). Value bits: 0 = water, 1 = river polarity, 2..4 =
flow Direction (1 E, 2 N, 3 W, 4 S); overworld data has 0, 1, 5, 9, 13, 17. With detect_holes every square is set to
water at load and the voxels of each OCEAN_HOLE Wall_Set whose ocean_hole_id names the map clear bit 0 again. The
save's OCEAN_CHANGE actions carry the new byte (see RULES.md §8). The water surface is sink below the map's z;
sink defaults to -1 (type initializer), and a negative sink means 0.1 (the ocean mesh builder in ocean.jai).
Built by scripts/build_map.py (data/map.json), scripts/build_world.py (data/world.json) and merged by scripts/build_progress.py. Level roles (Required / Optional and
the ice gates each level sits behind) are not stored: the viewer computes them from these fields (RULES.md "Level
roles"). Layer numbers
l are the ground-layer table indices (0 ground, 1 underworld, 2 sky, 3-5 fast-travel map rooms).
| Key | Shape |
|---|---|
map.water_rgb |
[r, g, b] open-water colour as drawn |
map.oceans[] |
{pid, layer (index into map.layers), x0, y0, sx, sy, zw, water}: square k = (x0 + k % sx, y0 + ⌊k / sx⌋) in world units, surface z zw, water = base64 bit mask of the initial bit 0 (bit k % 8 of byte k >> 3). Only drawn (non fast-travel, existing, not PLAYER_BLOCKING_ONLY) maps |
levels[] |
{n (level_name), p, req (indexes into levels), or, en, t (tower_flags), d (dropdown), a (achievement), set: [index into sets, place in the set], area (index into areas), areaNear (outside every area box, grouped with the nearest)} |
sets[] |
{name, n (levels in the set)}: each level's first level set that is not a drafts set |
freezers[] |
{pid, p, l, req, or, partial, judge}, and signs when some unlock ids are Signs: [{pid, rank} | {pid, tower: index into towers} | {pid, gold: 4} | {pid}] |
signs[] |
{pid, p, l, rank | tower (index) | gold: 4, status (a tower status sign), gates (indexes into freezers that list it)} |
towers[] |
{pid, t (tower_flags), req (levels_required), p} |
treasures |
number of Treasure_Displays |
voice_logs[] |
{pid, p, l, name, author, d (heard in the level data)} |
view_givers[] |
{pid, p, l}: the map scrolls |
areas[] |
{local_area_name, display, p (label position), pin (Area_Pin pid), boxes: [{p, q (rotation), s (scale), e (half extents), f (float_parameter), w (wrap widths)}], layer, order} |
fog |
{grid: {corner, screen, count, wrapping}, primary: [{pid, layer}], boxes: [{pid, layer, min, max}], levels: {level name: [layer, region]}, start: {hero, regions: {pid: [region]}, pins}} (fog.json) |
teleporters[] |
{pid, p: [x, y, z], l, major (1 MAJOR, else MINOR), target (pid), en (data enabled_t_target > 0), disc (data discovered), gold (gold_room_endpoint), ex (existence default, only for existence-group pads)} |
treasure_displays[] |
{pid, p, i = treasure_index}, sorted by i |
gold_rooms[] |
{tower (index), pad (teleporter index next to the tower), endpoint (teleporter index in the gold hub)} |
world.grid |
{corner, size, zoff}; cells are (i, j) = ((x - corner.x) mod W, (y - corner.y) mod H) |
world.solid |
zlib + base64 (JS DecompressionStream('deflate')): for each column j × W + i, u8 n then n u8 (z + zoff), ascending. Wall_Set voxels outside existence groups (OCEAN_HOLE excluded) |
world.zlayer |
[256] ground layer l of each integer z = index - zoff |
world.floors |
pid, i, j, z(, exists) Fences with blocking and is_floor: a floor at the bottom of their own cell (holds the hero at z, blocks vertical moves through it) |
world.groups[], world.group_cells
|
{pid, exists (data default), n}; group_cells = zlib + base64 of u8 triplets (i, j, z + zoff), n per group in list order |
world.elevators |
i, j, z, i2, j2, z2 Stairs frame pairs (other_end_id) |
world.oceans |
pid, z, l, i, j, sx, sy(, exists) every Ocean_Map (all kinds); square k is cell ((i + k % sx) mod W, (j + ⌊k / sx⌋) mod H); a save's OCEAN_CHANGE index is k |
world.water |
zlib + base64: for each column j × W + i, u8 n then n u16 LE indices into oceans, ascending: the maps with water (bit 0, after detect_holes) at that square in the level data |
world.jumps |
i, j, z, dirs cells inside a may_jump_into_water Marker box (position ± box_extents); dirs bit 1 << side (as fences) per letter of its string_parameter |
world.fences |
pid, i, j, z, side(, exists) blocking non-floor Fences; side 0 east, 1 north, 2 west, 3 south is the cell edge closed; exists (data default) only for existence-group members |
world.gates |
pid, i, j, z, open, notted, flavor, monster(, exists) Gates with their data open (a closed one blocks its cell; RULES.md §8 for how switches decide it) |
world.switches |
pid, flavor, pressed(, exists) Switches with their data pressed
|
world.rocks |
pid, i, j, z Rocks at their data position |
world.lilies |
pid, i, j, z Lily pads at their data position; standable in their own cell there and at their latest position |
world.area_pins |
pid, x, y, z |
world.mechs |
kind, pid, i, j, z(, exists) what the walk models only in part or not at all: 0 DRAGON Monster, 1 Promesst_Light or beam blocker Rock (*beam_blocker, *beam_barrier*), 2 Mirror or MIRRORABLE Rock, 3 SKIPPABLE Rock |
world.monsters |
pid, i, j, z, kind(, exists) every Monster at its data position (each one stops dragon fire); kind = a DRAGON's facing side (its +x axis, as fences), -1 a GOBLIN, -2 neither (RULES.md §8 "Dragon fire and PROTECTION") |
world.fire_stops |
i, j, z cells of Trees, Signs, Gems, Rovers, Friendly_Dragons, Mirrors and Promesst_Lights: dragon fire ends before them |
world.wardrobes |
pid, i, j, z, side, skill(, exists) Wardrobes on the grid axes; side (as fences) is the door edge; skill 1 PROTECTION, 2 another skill, 0 none (inventory only) |
world.guy |
[pid, protection] the overworld Guy; protection = its data skill_flags hold PROTECTION |