Custom Levels - joric/ootss GitHub Wiki

Translated from Chinese: https://github.com/qiekn/ootss-cmd/blob/main/docs/custom_levels.md

Custom Level Guide (Offline Package Modding)

This document describes the level file structure of the Order of the Sinking Star Demo, and how to build custom levels by using existing levels as templates, without relying on an in-game editor. All conclusions come from read-only analysis performed on 2026-09-20 on the local Demo (r62164, Steam public-testing branch); they need to be re-verified after any game update.

Verified: the release build has no usable editor or console switch. The only valid arguments listed by -help are -language, -size, -no_vsync, -windowed, -fullscreen, and -prefer_*. The actions in data/Developer.keymap (the F1 console, Insert to mark a level complete, and so on) do not exist in the executable; they are leftover files. Custom levels can therefore only be made by modifying the data packages.

1. What files make up a level

Taking mirror_1 as an example, a level is spread across four places:

Location File Contents Required
Inside data/levels.package data-common/mirror_1.entities All entities in the level: characters, mirrors, rocks, walls, doors, camera, sun, settings, etc. Yes
Inside data/levels.package data-common/mirror_1.level_manifest Text file listing the textures and meshes the level references, used for preloading Yes
Inside data/levels.package data/level_sets/mirror.level_set Text file; the level order table for one world One of the ways to enter a level
Loose in data-common/ mirror_1.all_paint_data (allp header) Vertex paint colors, linked to entities by entity ID No; if missing, only a log entry is written
Loose in data-common/ mirror_1.all_lightmap_data Baked lightmaps No; if missing, only a log entry is written
Loose in data/levels/mirror_1/ 13_1-probe.dds, etc. Reflection probe cubemaps (DDS); the file name is the probe entity's ID No

The entrance from the overworld is the Level_Entry entity in overworld.entities; there are 132 of them. Each one stores the target level name directly as a member, and the player enters that level by standing on it and pressing V.

At startup the game mounts data/levels.package, materials.package, shaders.package, and animation.package as a virtual file system, then adds the loose files from the data-common, data-pc, font, and sound directories to the catalog. When a mesh cannot be found, the game prints Could not find mesh "%" for entity %, using placholder. and uses a placeholder model instead of crashing.

2. The levels.package format

This is the format of the standard Jai module Simple_Package, so you can read and write it directly with that module:

  • 64-byte file header: the simp magic number, version 1, flags 0, and the directory offset.
  • Entry data is laid out one after another, followed by the directory: the toc! magic number, the entry count, and then for each entry a u32 name length, the name, a NUL, a u64 size, and a u64 offset.
  • This Demo has 238 entries in total: 114 .entities, 114 .level_manifest, and 10 .level_set files.

scripts/inspect_levels.jai uses this module to parse the package read-only and print its contents; see the end of this document.

3. .level_set: the level table for one world

Plain text. Example excerpt from data/level_sets/mirror.level_set:

[1]     # Version number; do not delete!

# Just mirrors, no rocks
mirror_1
mirror_2
# Now rocks as well
mirror_3
mirror_4 # optional
*choice
mirror_5
  • The first line, [1], is the version number.
  • Each line is one level name, corresponding to data-common/<name>.entities.
  • *choice is a choice point. It splits the levels into groups, and completing one group unlocks the next. Named choice points such as *worlds_collide and *choice_demo2 also exist.
  • Everything after # is a comment, and comments can also be added at the end of a line.

The files for the four worlds are: heroes1/2/3.level_set (the northern heroes), mirror.level_set (the eastern mirrors), promesst1_and_2.level_set (the western lasers; the Demo contains only the first level), and heroes_and_water.level_set (the waters). There are also worlds_collide.level_set (the mixed world), intro, intro_hard_puzzles, and overworld. Many levels listed in these tables (such as promesst2_streamlined and floating_gardens) have no matching .entities in the Demo, which shows that the game tolerates missing entries.

4. .level_manifest: the resource manifest

Plain text:

[1]

;;;;; textures
Base_Metal_A-MASK
GAME_MIRR_Mirror_Magic-diffuse
...

;;;;; meshes
GAME_MIRR_Mirror_Magic_A
GAME_SOKO_Exit_A
...

The names correspond to data-pc/<name>.texture and data-common/<name>.compressed_mesh. Before loading a level, the game preloads these resources asynchronously according to the manifest. A missing entry does not prevent the level from loading; the resource is simply read synchronously the first time it is displayed. Extra entries are harmless. Copy the template level's manifest and add the mesh names used by any new entities.

5. .entities: the entity table

Binary, with the enty magic number; this Demo uses version 7. Structure:

u8[4]  "enty"
u16    version            = 7
u64    header_value       = 25
u64    type_count         = 86
type_count times:
    u64 name_length
    u8[] name             e.g. "Guy", "Mirror"
    u64 aux               number of member versions for this type
u32    record_count
record_count times:
    u32 entity_id
    u16 type_index        points into the type table above
    u32 diff_size
    u8[] diff

All levels share the same 86-entry type table (in the same order), so type indices are interchangeable between levels. Entity IDs are unique within a level. Paint data and probe files are linked to entities by ID, so a new entity just needs an unused ID.

Record diff encoding

A diff stores only the members that differ from the type's defaults, which is why a Mirror record is only 188 bytes. Layout:

u16 member number          the first member (usually 0, i.e. the X coordinate)
member value
then for each further member:
    u8  0xFD
    u16 member number
    member value

Member numbers below 0x1000 belong to the base class Entity; numbers from 0x1000 up belong to the type's own members (0x1000 | the type's own member index). Confirmed members:

Member number Meaning Value encoding
0x0000 / 0x0001 / 0x0002 Position X / Y / Z 4-byte prefix + float
0x0006 – 0x0009 Orientation quaternion x/y/z/w 4-byte prefix + float
0x0016 – 0x0018 Three floats, default 1 (possibly color or scale) 4-byte prefix + float
0x001F Mesh name or character name, a string 8-byte prefix + u64 length + bytes
0x0021 – 0x0023 A second copy of the position (called out_x/y/z in the reference tool), identical to 0/1/2 4-byte prefix + float
Level_Entry 0x1000 Target level name, a string Same as 0x1F
Guy 0x1004 u32 character kind ID 4-byte prefix + u32
Guy 0x100E / 0x1011 Start / end marker names String
Settings 0x1054 Level name (same as the file name) String

The 4 bytes before a float are called the "version stamp" in the reference tool, but what is actually observed is that they equal the member's default value (X defaults to 0, quaternion w defaults to 1). Under either interpretation, when changing a coordinate, keep these 4 bytes and change only the following 4. A string is a u64 length followed by UTF-8 bytes, with no terminator.

Example (the Guy from mirror_1, entity ID 1048632):

0000  00000000 00001041      member 0 = 9.0     (X)
fd 0100  00000000 00001041   member 1 = 9.0     (Y)
fd 0600  00000000 00000080   member 6 = -0.0
fd 0700  00000000 00000080   member 7 = -0.0
fd 0800  00000000 f30435bf   member 8 = -0.7071
fd 0900  0000803f f304353f   member 9 =  0.7071 (90° rotation about Z)
...
fd 1f00  00000000 00000000  0600000000000000 "trader"      character name
...
fd 0410  00000002 00000000                                 character kind
fd 0e10  ... 1200000000000000 "mirror_new_1_start"          start marker
fd 1110  ... 1000000000000000 "mirror_new_1_end"            end marker

The names of the remaining members can only be determined by reading the reflection metadata from the running game. The project already has code that iterates over the Guy members by name (src/game/position_layout.jai); the member number is very likely the index into the reflected member array, so extending that code would let you export a "member number → name, type" table.

What a minimal level contains

The 340 records of mirror_1, counted by type:

Type Count Purpose
Settings 1 Level settings: camera control mesh, level name, owning world, etc.
Camera_Control 1 Camera position and orientation; if missing, the game asks for the level to be re-saved
Sun 1 Sunlight; if missing, same as above
Sky, Fog, Wind, Ocean_Map 1 each Environment
Wall_Set 4 A set of walls; members include start point, direction, and length
Guy 1 The player character trader
Mirror 1 The mirror, mesh GAME_MIRR_Mirror_Magic_A
Door 1 The exit, mesh GAME_SOKO_Exit_A
Marker 1 Start/end markers
Inanimate 275 Purely decorative terrain and props
Decal, Light_Probe, Fog_Volume, Group Several Decals, light probes, fog volumes, groups

Settings, Camera_Control, and Sun are hard requirements (the executable contains matching error messages). Guy and Door determine whether the level is playable. Everything else can be removed.

Gameplay entities by world

World Types Description
Common Guy, Rock, Door, Wall_Set, Gate, Switch, Monster, Gem, Gem_Slot, Freezer, Teleporter Character, rocks, exit, walls, gates, switches, monsters, gems and gem slots, ice, teleporters
East Mirror Mirrors, used in 31 levels
West Promesst_Light Laser base (mesh GAME_PROM_Laser_Base_A); used in only 4 levels in the Demo
North The Guy kind ID distinguishes warrior / thief / wizard / priestess / bard / druid In the keymap file, 1–9 correspond to Warrior/Thief/Wizard/Priestess/Bard/Druid/Sailor/Gemsman/Trader
Waters Ocean_Map, Lily, Elevator_Platform Water surface, lily pads, elevator platforms

Official levels such as worlds_collide_heroes_and_mirrors_v3 already mix heroes, mirrors, and lasers in a single level, which shows that the engine does not restrict cross-world combinations.

6. Workflow

To make a level called my_level using mirror_1 as the template:

  1. Back up data/levels.package. Steam's file integrity check will restore your changes, so a modified package only takes effect locally.
  2. Extract the template: use Simple_Package to read data-common/mirror_1.entities and .level_manifest.
  3. Rename: change the entry names to data-common/my_level.entities / .level_manifest, and change the 0x1054 string in the Settings record to my_level (if the string gets longer, update the u64 length and the record's diff_size accordingly). Just keep the Guy's start/end marker names and the Marker names paired.
  4. Edit entities:
    • Move: change the floats of members 0/1/2 and 0x21/0x22/0x23. Grid coordinates are integers, and Z is generally 0.
    • Copy: duplicate the whole record and change the entity ID. When copying across levels, the type index does not need to change.
    • Add a laser: copy a Promesst_Light record from promesst1_streamlined, change its coordinates and orientation, and add GAME_PROM_Laser_Base_A to the manifest.
    • Delete: just remove the record and decrease record_count. Removing Inanimate decorations does not affect gameplay.
    • Walls: the own members of Wall_Set include an integer start point, direction, and length; the exact member numbers are pending the reflection table, so translating walls only is the safer approach for now.
  5. Hook it up to an entrance, in one of two ways:
    • Add a line my_level to a .level_set (text; the simplest option, but levels can only be unlocked in sequence).
    • Copy a Level_Entry in overworld.entities, change its coordinates and the target level name in 0x1000, and place it on a free spot on the overworld.
  6. Rebuild the package: use Simple_Package.Create_Package to write all entries (including the new ones) back into levels.package. The loose paint, lightmap, and probe files do not need to be copied for the level to run; you just won't get vertex colors or baked lighting.
  7. Test: launch the game and check the newest log in logs/ for Loading level 'my_level' and any warnings that follow.

7. Risks and limitations

  • Only local files are modified and no game memory is written; mistakes can cause load failures or crashes, and the log will point out the cause.
  • Until the reflection table exists, only coordinates, orientations, names, and level references are safe to modify.
  • The save directory Saved Games/Order of the Sinking Star/<save name>/ stores .history files and completion state by level name, so do not give a custom level the same name as an official one.
  • Resources missing from the Demo (meshes referenced by levels in the full release) cannot be conjured up; you can only use the meshes that exist.

8. Tools

A read-only parsing script (Jai #run; it does not modify game files):

# List the raw level sets, the resource manifests, and entity type usage statistics for all 114 levels
jai -quiet scripts/inspect_levels.jai - "<game directory>/data/levels.package"

# For one level: the raw manifest, entity type counts, and all records
jai -quiet scripts/inspect_levels.jai - "<game directory>/data/levels.package" mirror_1

# For one type in one level: record strings and hex dumps
jai -quiet scripts/inspect_levels.jai - "<game directory>/data/levels.package" overworld Level_Entry

If real level creation is needed later, two more tools are planned: one that exports a "member number → name" table from the running game, and an extract / replace / rebuild script based on Simple_Package.

⚠️ **GitHub.com Fallback** ⚠️