Save Format - joric/ootss GitHub Wiki

Order of the Sinking Star save formats

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.

settings.menu_state

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

Status.sokoban_status (campaign status)

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.

.history (undo state)

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

Entity diff blob

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.

Metadata serialization (Move_Transaction, Single_Move)

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.

data/levels.package

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)

.entities

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).

Map data

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.

data-common/.all_paint_data

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.

data-pc/.compressed_mesh (decompressed)

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.

data-pc/.texture

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.

data/materials.package

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).

Overworld state for the viewer (data/progress.json additions)

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
⚠️ **GitHub.com Fallback** ⚠️