Custom Levels - joric/ootss GitHub Wiki
Translated from Chinese: https://github.com/qiekn/ootss-cmd/blob/main/docs/custom_levels.md
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.
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.
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
simpmagic 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_setfiles.
scripts/inspect_levels.jai uses this module to parse the package read-only and print its contents; see the end of this document.
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. -
*choiceis a choice point. It splits the levels into groups, and completing one group unlocks the next. Named choice points such as*worlds_collideand*choice_demo2also 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.
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.
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.
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.
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.
| 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.
To make a level called my_level using mirror_1 as the template:
-
Back up
data/levels.package. Steam's file integrity check will restore your changes, so a modified package only takes effect locally. -
Extract the template: use
Simple_Packageto readdata-common/mirror_1.entitiesand.level_manifest. -
Rename: change the entry names to
data-common/my_level.entities/.level_manifest, and change the 0x1054 string in the Settings record tomy_level(if the string gets longer, update the u64 length and the record'sdiff_sizeaccordingly). Just keep the Guy's start/end marker names and the Marker names paired. -
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_Lightrecord frompromesst1_streamlined, change its coordinates and orientation, and addGAME_PROM_Laser_Base_Ato the manifest. - Delete: just remove the record and decrease
record_count. Removing Inanimate decorations does not affect gameplay. - Walls: the own members of
Wall_Setinclude 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.
-
Hook it up to an entrance, in one of two ways:
- Add a line
my_levelto a.level_set(text; the simplest option, but levels can only be unlocked in sequence). - Copy a
Level_Entryinoverworld.entities, change its coordinates and the target level name in 0x1000, and place it on a free spot on the overworld.
- Add a line
-
Rebuild the package: use
Simple_Package.Create_Packageto write all entries (including the new ones) back intolevels.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. -
Test: launch the game and check the newest log in
logs/forLoading level 'my_level'and any warnings that follow.
- 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.historyfiles 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.
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_EntryIf 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.