Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sora

Sora helps you keep game configuration data understandable while still giving runtime code typed access.

You write a schema that describes table shapes, fill the table rows in Excel, CSV, TOML, JSON, or YAML, and let Sora validate the data. After validation, Sora writes a runtime data bundle and generates code that knows how to load that bundle.

The schema is the contract. Excel, CSV, TOML, generated code, and exported runtime bundles are all projections of that contract. A designer can edit rows in a workbook, while game code consumes strongly typed generated APIs.

For a small project, the file flow looks like this:

project.scon
  -> schema/items.scon
  -> data/Item.xlsx
  -> generated/config.sora
  -> generated/rust

You normally hand-write project.scon and schema files. Designers or tools edit files under data/. Files under generated/ are Sora outputs.

What Sora Does

schema modules -> Excel/CSV/TOML/JSON/YAML data -> validation
                                      |-> runtime bundle
                                      |-> generated code

Sora currently focuses on these stages:

  • describe tables, records, enums, unions, references, indexes, and validation rules in schema files;
  • inspect and edit schema modules in the embedded Sora Studio UI;
  • generate Excel templates from the schema so spreadsheet headers stay consistent;
  • load table data from TOML, JSON, YAML, CSV, or Excel .xlsx;
  • validate data against the normalized schema and cross-table references;
  • export data as Sora binary, debug JSON, JSON bundle, CBOR bundle, or Sora Protobuf bundle;
  • generate language runtimes that load those exported bundles.

Common Terms

Sora uses the word format in a few different places:

TermMeaningExample
Schema formatThe file format used to write schema/project files.SCON (recommended), TOML, YAML, JSON, Lua
Source formatThe editable table data format.Excel .xlsx, CSV, TOML, JSON, YAML
Export formatThe data bundle written after validation.binary, json, cbor
Runtime formatThe bundle format generated code expects to load.sora, json, cbor

For example, Rust codegen with runtime_format = "sora" needs a matching binary export. The source data can still come from Excel.

When This Fits

Sora is intended for game configuration and similar data-heavy applications where:

  • designers or tools edit tabular data;
  • runtime code wants typed access instead of loose dictionaries;
  • schema changes should be reviewed in source control;
  • generated language support should be extendable by downstream users.

The project is still early, so the public API can change. The design goal is to keep the core schema and IR independent from individual language backends, so downstream users can add generators or exporters without patching the core pipeline.

Projects that need stable output should pin the sora CLI version. Runtime/export format versions are bumped only for actual generated-runtime incompatibility; Sora does not currently maintain old schema semantics behind edition flags. See Versioning and Compatibility.

Suggested Reading Order

Start with Quick Start, then read Sora Studio, First Config, and Excel Workflow. After that, the most useful reference pages are Types, Tables, Cell Parsers, References and Derived Fields, and Versioning and Compatibility.

Design notes and extension pages are meant for readers who already understand the basic build flow.

Core Concepts

Project

A project manifest declares the project ID, schema modules, views, build outputs, codegen targets, and export targets. It is the entry point used by sora check, sora build, sora gen, and sora export.

Schema

Schema files describe the shape of configuration data. They define enums, structs, unions, tables, indexes, references, and field rules. Sora normalizes schema files into an IR before validation, export, or code generation.

Table

A table is a named collection of rows. Tables can be list-like, keyed by one field, or singleton. Source metadata tells Sora where the editable data comes from.

The table schema is also used to generate editor projections such as Excel headers. The spreadsheet is not the contract; it is one way to edit rows that conform to the contract.

Value

Sora validates source cells into a common value tree before export. Generated runtimes read that same shape from different runtime formats, so a target language can switch between sora, json, cbor, or sora-protobuf without changing the schema.

Runtime Format

A runtime format is the wire format that generated code loads. It is selected per language target with runtime_format.

Generator

A generator is a language backend registered in the codegen registry. Built-in generators are ordinary registry entries, which keeps the pipeline open to downstream extensions.

Exporter

An exporter writes validated data into a runtime bundle. The exporter registry is separate from code generation so data formats and language targets can evolve independently.

Scope

Schema entities and fields belong to declared groups. Named views select groups, stable table IDs, aliases, and target-language bindings to form an external contract for code generation and export.

Quick Start

This guide builds a minimal item table, generates an Excel template, exports a runtime bundle, and generates Rust code that can load it.

Install the CLI from the GitHub Releases page by downloading the archive for your platform and placing the sora binary on your PATH.

If you already have a Rust toolchain, you can also install the published package from crates.io:

cargo install sora-cli

For local development from a checkout:

cargo install --path crates/sora-cli

1. Create a Project

The fastest path is to scaffold the same minimal project:

sora init --out my-config
cd my-config

SCON is the default. --schema-format can explicitly select scon, toml, yaml, json, or lua. The scaffold creates this layout:

PathWho edits itPurpose
project.sconYouProject entry point, build outputs, default data location.
schema/items.sconYouSchema for the Item table.
data/Item.xlsxDesigners or toolsEditable row data.
generated/SoraSchema lock, Excel templates, generated code, exported data.

The rest of this section shows the generated files so you can understand the project shape. project.scon looks like this:

project { id = "game_config" }
groups { common { default = true } }
views {
  default {
    contract = "game_config/default"
    groups = ["common"]
  }
}
includes = ["schema/items.scon"]

codegen {
  rust {
    crate { name = "game-config" }
  }
}

build {
  default_source_format = "xlsx"
  data_root = "data"
  view = "default"
  schema_lock = "generated/schema.lock"
  excel_templates = "generated/excel"
  codegen = [
    { target = "rust", out = "generated/rust", format = "auto" },
  ]
  exports = [
    { format = "binary", out = "generated/config.sora" },
  ]
}

In this file, default_source_format = "xlsx" means table sources default to Excel. data_root = "data" means Item.xlsx is read from data/Item.xlsx during export and build. excel_templates = "generated/excel" is only the generated template output directory. It is where Sora writes fresh workbooks with schema headers; it is not the source data directory. Keep it separate from data so regenerating templates cannot overwrite edited row data. codegen.rust.crate makes generated/rust a standalone library crate with its own Cargo.toml. The binary export writes the runtime bundle that Rust code will load because Rust defaults to runtime_format = "sora".

Create schema/items.scon:

enums {
  ItemType = ["Weapon", "Armor", "Material", "Consumable"]
}

tables {
  Item {
    mode = "map"
    key = "id"
    source {
      format = "xlsx"
      file = "Item.xlsx"
      sheet = "Item"
    }
    fields {
      id { type = "i32" comment = "Item id" }
      name { type = "string" comment = "Display name" }
      item_type { type = "enum<ItemType>" comment = "Item category" }
      max_stack {
        type = "i32"
        default = "1"
        range = [1, 9999]
        comment = "Stack limit"
      }
    }
  }
}

2. Generate the Excel Template

The workbook header is generated from the schema:

sora excel-template --project project.scon --out generated/excel

This creates generated/excel/Item.xlsx. Treat that file as a template artifact that can be regenerated after schema changes. For a new table, copy it to data/Item.xlsx and fill rows below the generated header:

idnameitem_typemax_stack
1001Iron SwordWeapon1
2001Health PotionConsumable99

After you have real data in data/Item.xlsx, do not run excel-template --out data unless you intentionally want to replace those files. Keep generating empty templates into generated/excel, and use excel-sync to update existing data workbooks in place when the schema changes.

For existing data workbooks, prefer syncing headers in place:

sora excel-sync --project project.scon --data-root data
sora excel-sync --project project.scon --data-root data --write

The preview command shows added fields and legacy columns. The --write command refreshes generated header rows while preserving data rows; fields removed from schema stay in Excel as legacy columns that Sora ignores.

3. Check, Export, and Generate

Validate the schema without reading row data:

sora check --project project.scon

Run every output declared in [build]. This also loads and validates source data before writing exports:

sora build --project project.scon

You can also open the project in Sora Studio, the schema editor embedded in the CLI:

sora studio --project project.scon

The command prints a local URL. Open it in a browser to visualize schema relationships, edit schema modules, preview the generated changes, and save them back to the project.

Or run the steps separately:

sora gen --target rust --project project.scon --out generated/rust

sora export \
  --format binary \
  --default-source-format xlsx \
  --project project.scon \
  --data-root data \
  --out generated/config.sora

4. Next Steps

Read Sora Studio if you want to edit schemas visually. Read First Config for the same example with the generated runtime usage, or inspect examples/showcase/project.scon for a larger multi-language setup.

Tutorials

Tutorials walk through Sora from an application user’s point of view.

Start with First Config to build a minimal table end to end. Then read Excel Workflow to understand generated spreadsheet templates and Load Generated Code to connect exported data to runtime code.

First Config

This tutorial creates a small item configuration table. The same pattern scales to larger game data: define the schema, generate an editable workbook, fill rows, export a runtime bundle, and generate code.

Project Layout

project.scon
schema/items.scon
data/Item.xlsx
generated/

Project Manifest

project { id = "game_config" }
groups { common { default = true } }
views {
  default {
    contract = "game_config/default"
    groups = ["common"]
  }
}
includes = ["schema/items.scon"]

codegen {
  rust {
    crate { name = "game-config" }
  }
}

build {
  default_source_format = "xlsx"
  data_root = "data"
  view = "default"
  schema_lock = "generated/schema.lock"
  excel_templates = "generated/excel"
  codegen = [{ target = "rust", out = "generated/rust", format = "auto" }]
  exports = [{ format = "binary", out = "generated/config.sora" }]
}

schema_lock captures the normalized schema, excel_templates writes workbooks with generated headers, build.codegen declares language output, and build.exports declares runtime data output.

Schema

enums { ItemType = ["Weapon", "Armor", "Material", "Consumable"] }

tables {
  Item {
    mode = "map"
    key = "id"
    source { format = "xlsx" file = "Item.xlsx" sheet = "Item" }
    fields {
      id { type = "i32" comment = "Item id" }
      name { type = "string" comment = "Display name" }
      item_type { type = "enum<ItemType>" comment = "Item category" }
      max_stack { type = "i32" default = "1" range = [1, 9999] comment = "Stack limit" }
    }
  }
}

This table uses mode = "map", so the generated runtime exposes keyed lookup by id.

Excel Template

Generate a workbook:

sora excel-template --project project.scon --out generated/excel

The generated sheet has metadata rows above the editable data area:

#fieldidnameitem_typemax_stack
#typei32stringenum<ItemType>i32
#inputkeyrange=1..9999
#descItem idDisplay nameItem categoryStack limit

Rows start after the generated header:

idnameitem_typemax_stack
1001Iron SwordWeapon1
2001Health PotionConsumable99

Copy the workbook to data/Item.xlsx after generating it, or point your source file at the generated location during experiments.

Build

Run the configured outputs:

sora build --project project.scon

Expected artifacts:

  • generated/schema.lock
  • generated/excel/Item.xlsx
  • generated/rust/Cargo.toml
  • generated/rust/src/lib.rs
  • generated/config.sora

Use sora check --project project.scon when you only want schema validation.

Excel Workflow

Excel support is designed around generated templates. The schema owns the table shape; Excel is an editable projection of that schema.

Generate Templates

There are two ways to generate Excel templates.

The direct command only writes templates:

sora excel-template --project project.scon --out generated/excel

This reads the schema from project.scon and writes generated workbooks under generated/excel. The directory is safe to delete and regenerate because it should contain template artifacts, not hand-edited source data.

The build workflow can do the same thing when excel_templates is configured:

build { excel_templates = "generated/excel" }
sora build --project project.scon

Both paths generate the same kind of template files. The direct command only writes Excel templates. sora build runs the template output together with the other configured build outputs such as schema locks, code generation, and exports.

Template Directory vs Data Directory

excel_templates is an output directory for templates. It is not the runtime data input directory. Data input normally comes from [build].data_root or the --data-root command option.

The usual layout keeps these paths separate:

PathRoleCan be regenerated
generated/excelGenerated workbook templates with schema headers.Yes
dataEdited table rows used by export and build.No

Do not point excel-template --out or [build].excel_templates at a directory that already contains edited data workbooks unless replacing those files is intentional. Use generated templates for new workbooks; use excel-sync for workbooks that already contain real data.

Sync Existing Workbooks

For real projects with existing data, use excel-sync instead of copying rows into a fresh template. It updates workbook headers from the current schema while preserving data rows:

sora excel-sync --project project.scon --data-root data

Without --write, the command only previews what would change. To write the updated workbook files:

sora excel-sync --project project.scon --data-root data --write

When writing an existing workbook, Sora first copies the old file under data/.sora-backup/<unique-batch>/. The backup directory ignores itself from Git and retains only the 20 most recent batches.

Sync matches columns by the #field row, not by column position:

  • existing schema fields keep their data;
  • new schema fields are added as empty columns;
  • changed type, parser, groups, range, length, comments, and table metadata refresh the generated header rows;
  • fields removed from schema are not deleted from Excel. They are kept as legacy columns ignored by Sora, so designers can delete them manually when they are ready;
  • non-schema sheets in the same workbook are preserved as value-only sheets.

The XLSX file is the ownership boundary for one table definition. Two table definitions cannot reference the same workbook. Multiple worksheets in one workbook are supported only as row partitions of that one table, for example monthly activity data:

tables {
  Activity {
    mode = "map"
    source { format = "xlsx" file = "Activity.xlsx" sheets = ["2026-01", "2026-02"] }
  }
}

Both sheets use the Activity schema and their rows are combined during loading. To bind another table, give it another file:

tables {
  Skill { mode = "map" source { format = "xlsx" file = "Skill.xlsx" } }
}

If sheet and sheets are omitted, a new template derives its default Sheet name from the workbook filename. For example, table presentation.SurfaceResourceVisualProfile with file = "VisualProfile.xlsx" uses the Sheet VisualProfile; the namespace never enters the physical Sheet name. The default is sanitized and truncated to Excel’s 31-character limit. The complete canonical table name remains in each data Sheet’s @table metadata. Explicit sheet and sheets values are preserved.

Header Rows

Generated sheets include several header rows:

RowPurpose
@table metadataCanonical table name, mode, key, groups, and schema hash.
#nameDisplay name row for the spreadsheet.
#fieldStable schema field names read by Sora.
#typeType hints such as i32, enum<ItemType>, or struct<Cost>(kind: enum<ResourceKind>, id: i32, count: i32).
#groupsGroup membership for each field.
#inputInput hints such as key, parser, range, length, or derived-field source.
#descField comments for designers and reviewers.

Data rows start after the generated header.

What Users Should Edit

Users should edit data rows. They should not hand-maintain field names, types, key metadata, input hints, or validation rules in Excel. Those rows are regenerated from schema changes.

If a column’s #input cell starts with from=, that field is derived from another table. Leave the generated placeholder in that column and edit the child table rows instead.

When the schema changes, run sora excel-sync --project project.scon --data-root data to preview header changes, then rerun with --write after reviewing them. This keeps spreadsheet editing convenient without making Excel a second schema language.

Common Field Shapes

Simple fields map directly to cells:

idnamemax_stack
1001Iron Sword1

Structured values use parsers when a cell needs a compact representation:

[[tables.fields]]
name = "price"
type = "struct<ResourceCost>"
parser = { kind = "tuple" }
comment = "Tuple: kind,id,count"

Example cell:

Item,1001,3

Collections can use JSON or map-style parsers:

[[tables.fields]]
name = "tags"
type = "set<string>"
parser = { kind = "json" }
default = "[\"misc\"]"

[[tables.fields]]
name = "attributes"
type = "map<string,i32>"
parser = { kind = "map" }
comment = "Map pairs: key,value|key,value"

Example cells:

["starter","melee"]
attack,12|speed,2

Load Generated Code

Generated code contains strongly typed row models, table containers, and a config loader for the selected runtime format.

Choose a Runtime Format

[codegen.rust]
runtime_format = "sora"

The runtime format selected by code generation must match an exported bundle:

[[build.exports]]
format = "binary"
out = "generated/config.sora"

runtime_format = "sora" corresponds to the binary export. json, cbor, and sora-protobuf correspond to their matching export formats.

Rust Example

For a standalone generated crate, configure Rust codegen like this:

build {
  codegen = [{ target = "rust", out = "generated/game-config" }]
}

codegen {
  rust {
    runtime_format = "sora"
    crate { name = "game-config" }
  }
}

Add it to the consuming application’s manifest:

[dependencies]
game-config = { path = "../generated/game-config" }
use game_config::{SoraConfig, runtime::SoraBundle};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let bytes = std::fs::read("generated/config.sora")?;
    let bundle = SoraBundle::parse(&bytes)?;
    let config = SoraConfig::from_source(&bundle)?;

    if let Some(item) = config.item().get(&1001) {
        println!("{} stacks to {}", item.name, item.max_stack);
    }

    Ok(())
}

Exact names are derived from schema names and target language conventions. For example, a table named Item generally becomes an item row type plus an item table accessor.

Omit codegen.rust.crate to keep generating a module directory for inclusion with mod generated; inside an existing crate.

Adapter Targets

Some targets expose adapter hooks for formats where the ecosystem dependency should be supplied by the application. For example, Lua, Erlang, and Dart can accept decode_cbor or decode_sora_protobuf functions instead of embedding a specific third-party decoder.

See Runtime Adapters for examples.

Schema

A project root and each included schema module may use SCON, TOML, YAML, JSON, or Lua. SCON is recommended for new files.

project { id = "game_config" }
includes = ["schema/items.scon", "schema/skills.scon"]

Every file is first parsed into a format-specific keyed representation and then lowered to a format-independent schema module. The root must declare project; included modules must not declare root-only project, groups, views, codegen, or localization. Includes may cross formats and are loaded recursively in declaration order, with cycle, duplicate-module, and duplicate-declaration checks.

Declaration names are keys and are not repeated inside bodies. Unknown properties are rejected.

Namespaces and Imports

Each module may declare a logical namespace and aliases for other namespaces:

namespace = "game.items"
imports {
  common = "game.common"
}

structs {
  Drop {
    fields {
      reward = "struct<common.Reward>"
    }
  }
}

Declarations stay local in the source file: Drop above has the canonical name game.items.Drop. An unqualified reference such as struct<Cost> resolves in the current namespace, an alias-qualified reference such as common.Reward expands through imports, and any other dotted reference is an absolute project-qualified name. ref<items.Item.id> treats the final segment as the field and everything before it as the table name.

Namespace, import-alias, and declaration segments must be ASCII identifiers. The empty namespace is the project root namespace. Canonical qualified names are used by validation, schema locks, fingerprints, bundles, and runtime table lookup.

Enums

enums {
  ItemType = ["Weapon", "Armor", "Material"]
}

Enums keep their value order and are stored symbolically in editable data.

Structs

structs {
  Cost {
    fields {
      gold = "i32"
    }
  }
}

Struct fields preserve author order and model reusable nested values.

Unions

unions {
  RewardAction {
    tag = "type"
    variants {
      AddItem {
        fields {
          item_id = "ref<Item.id>"
        }
      }
    }
  }
}

Union variants preserve author order. tag is the discriminator used in source and runtime values.

Tables

tables {
  Item {
    mode = "map"
    key = "id"
    source {
      format = "xlsx"
      file = "Item.xlsx"
      sheet = "Item"
    }
    fields {
      id = "i32"
    }
  }
}

See Schema Formats for equivalent natural forms in all five frontends, Tables for sources and indexes, Types for field types, and Cell Parsers for compact cell encodings.

Schema Formats

Sora supports SCON, TOML, YAML, JSON, and Lua as equal schema frontends. SCON is recommended for new projects. Every frontend lowers to the same schema model, so validation, code generation, Excel templates, exports, and schema locks have the same semantics.

ExtensionFormatNotes
.sconSCONRecommended; concise object blocks.
.tomlTOMLUses nested named tables.
.yaml, .ymlYAMLUses keyed mappings.
.jsonJSONUses keyed objects and does not allow source comments.
.luaLuaReturns a data-only table; ordered members use singleton entries.

The file extension selects the frontend. includes are resolved relative to the file that declares them, loaded in declaration order, and may freely mix all five formats.

Project root and included modules

The project root must contain project and declare at least one group and one view. It may also contain codegen, localization, and build configuration. An included module must not contain those root-only properties; it normally contains namespace, imports, declarations, and further includes.

This is the same minimal project root in every format. Choose the block matching the extension of your project file and change items to the extension of the module you want to use.

SCON project root

project { id = "game_config" }
groups { common { default = true } }
views { default { contract = "game_config/default", groups = ["common"] } }
includes = ["schema/common.scon", "schema/items.scon"]

TOML project root

project = { id = "game_config" }
groups = { common = { default = true } }
views = { default = { contract = "game_config/default", groups = ["common"] } }
includes = ["schema/common.scon", "schema/items.toml"]

YAML project root

project: { id: game_config }
groups:
  common: { default: true }
views:
  default: { contract: game_config/default, groups: [common] }
includes: [schema/common.scon, schema/items.yaml]

JSON project root

{
  "project": { "id": "game_config" },
  "groups": { "common": { "default": true } },
  "views": {
    "default": { "contract": "game_config/default", "groups": ["common"] }
  },
  "includes": ["schema/common.scon", "schema/items.json"]
}

Lua project root

return {
  project = { id = "game_config" },
  groups = { common = { default = true } },
  views = {
    default = { contract = "game_config/default", groups = { "common" } },
  },
  includes = { "schema/common.scon", "schema/items.lua" },
}

The examples below use this shared module so that the common import resolves to a real declaration:

# schema/common.scon
namespace = "game.common"

structs {
  Reward {
    fields { code = "string" }
  }
}

Equivalent complete modules

Each of the following items modules declares exactly the same schema. They demonstrate namespaces and imports, enum and enum-value comments, aliases, structs, unions, references, constraints, parsers, sources, and indexes.

Declaration names are mapping keys; do not repeat a name property inside a declaration body. The comment property is schema metadata used in schema locks and generated documentation. It is supported on enums, enum values, and fields.

The enum alias SSR = "Epic" means source data may use SSR, which Sora normalizes to the canonical enum value Epic. It does not create an additional generated enum member.

SCON

namespace = "game.items"
imports { common = "game.common" }

enums {
  Rarity {
    comment = "Item rarity"
    values = [
      { id = 0, name = "Common", comment = "Common item" },
      { id = 1, name = "Epic", comment = "Rare item" },
    ]
    aliases { SSR = "Epic" }
  }
}

structs {
  StatBonus {
    fields {
      attack {
        type = "i32"
        comment = "Flat attack bonus"
        range = [0, 9999]
      }
    }
  }
}

unions {
  Grant {
    tag = "kind"
    variants {
      Item {
        fields {
          item_id = "ref<Item.id>"
          count { type = "u16", default = "1" }
        }
      }
      Currency {
        fields {
          amount { type = "i32", range = [1, 999999] }
        }
      }
    }
  }
}

tables {
  Item {
    mode = "map"
    key = "id"
    source { format = "xlsx", file = "Items.xlsx", sheet = "Item" }
    fields {
      id { type = "u32", comment = "Stable item id", range = [1, 999999] }
      name { type = "string", length = [1, 64] }
      rarity = "enum<Rarity>"
      bonus { type = "optional<struct<StatBonus>>", parser = "json" }
      reward { type = "optional<struct<common.Reward>>", parser = "json" }
      tags {
        type = "list<string>"
        parser = { kind = "split", separator = "|" }
      }
    }
    indexes {
      by_name { fields = ["name"], unique = true }
      by_rarity = ["rarity"]
    }
  }
}

TOML

namespace = "game.items"
imports = { common = "game.common" }

[enums.Rarity]
comment = "Item rarity"
values = [
  { id = 0, name = "Common", comment = "Common item" },
  { id = 1, name = "Epic", comment = "Rare item" },
]
aliases = { SSR = "Epic" }

[structs.StatBonus.fields]
attack = { type = "i32", comment = "Flat attack bonus", range = [0, 9999] }

[unions.Grant]
tag = "kind"

[unions.Grant.variants.Item.fields]
item_id = "ref<Item.id>"
count = { type = "u16", default = "1" }

[unions.Grant.variants.Currency.fields]
amount = { type = "i32", range = [1, 999999] }

[tables.Item]
mode = "map"
key = "id"

[tables.Item.source]
format = "xlsx"
file = "Items.xlsx"
sheet = "Item"

[tables.Item.fields]
id = { type = "u32", comment = "Stable item id", range = [1, 999999] }
name = { type = "string", length = [1, 64] }
rarity = "enum<Rarity>"
bonus = { type = "optional<struct<StatBonus>>", parser = "json" }
reward = { type = "optional<struct<common.Reward>>", parser = "json" }
tags = { type = "list<string>", parser = { kind = "split", separator = "|" } }

[tables.Item.indexes]
by_name = { fields = ["name"], unique = true }
by_rarity = ["rarity"]

YAML

namespace: game.items
imports:
  common: game.common

enums:
  Rarity:
    comment: Item rarity
    values:
      - { id: 0, name: Common, comment: Common item }
      - { id: 1, name: Epic, comment: Rare item }
    aliases:
      SSR: Epic

structs:
  StatBonus:
    fields:
      attack:
        type: i32
        comment: Flat attack bonus
        range: [0, 9999]

unions:
  Grant:
    tag: kind
    variants:
      Item:
        fields:
          item_id: "ref<Item.id>"
          count: { type: u16, default: "1" }
      Currency:
        fields:
          amount: { type: i32, range: [1, 999999] }

tables:
  Item:
    mode: map
    key: id
    source: { format: xlsx, file: Items.xlsx, sheet: Item }
    fields:
      id: { type: u32, comment: Stable item id, range: [1, 999999] }
      name: { type: string, length: [1, 64] }
      rarity: "enum<Rarity>"
      bonus: { type: "optional<struct<StatBonus>>", parser: json }
      reward: { type: "optional<struct<common.Reward>>", parser: json }
      tags:
        type: "list<string>"
        parser: { kind: split, separator: "|" }
    indexes:
      by_name: { fields: [name], unique: true }
      by_rarity: [rarity]

JSON

{
  "namespace": "game.items",
  "imports": { "common": "game.common" },
  "enums": {
    "Rarity": {
      "comment": "Item rarity",
      "values": [
        { "id": 0, "name": "Common", "comment": "Common item" },
        { "id": 1, "name": "Epic", "comment": "Rare item" }
      ],
      "aliases": { "SSR": "Epic" }
    }
  },
  "structs": {
    "StatBonus": {
      "fields": {
        "attack": {
          "type": "i32",
          "comment": "Flat attack bonus",
          "range": [0, 9999]
        }
      }
    }
  },
  "unions": {
    "Grant": {
      "tag": "kind",
      "variants": {
        "Item": {
          "fields": {
            "item_id": "ref<Item.id>",
            "count": { "type": "u16", "default": "1" }
          }
        },
        "Currency": {
          "fields": {
            "amount": { "type": "i32", "range": [1, 999999] }
          }
        }
      }
    }
  },
  "tables": {
    "Item": {
      "mode": "map",
      "key": "id",
      "source": { "format": "xlsx", "file": "Items.xlsx", "sheet": "Item" },
      "fields": {
        "id": {
          "type": "u32",
          "comment": "Stable item id",
          "range": [1, 999999]
        },
        "name": { "type": "string", "length": [1, 64] },
        "rarity": "enum<Rarity>",
        "bonus": { "type": "optional<struct<StatBonus>>", "parser": "json" },
        "reward": {
          "type": "optional<struct<common.Reward>>",
          "parser": "json"
        },
        "tags": {
          "type": "list<string>",
          "parser": { "kind": "split", "separator": "|" }
        }
      },
      "indexes": {
        "by_name": { "fields": ["name"], "unique": true },
        "by_rarity": ["rarity"]
      }
    }
  }
}

Lua

Lua schema files must return one data-only table. Filesystem, process, package, and debug libraries are unavailable. Enum values, fields, and union variants have semantic order, so Lua represents ordered named collections as lists of singleton tables.

return {
  namespace = "game.items",
  imports = { common = "game.common" },
  enums = {
    Rarity = {
      comment = "Item rarity",
      values = {
        { id = 0, name = "Common", comment = "Common item" },
        { id = 1, name = "Epic", comment = "Rare item" },
      },
      aliases = { SSR = "Epic" },
    },
  },
  structs = {
    StatBonus = {
      fields = {
        {
          attack = {
            type = "i32",
            comment = "Flat attack bonus",
            range = { 0, 9999 },
          },
        },
      },
    },
  },
  unions = {
    Grant = {
      tag = "kind",
      variants = {
        {
          Item = {
            fields = {
              { item_id = "ref<Item.id>" },
              { count = { type = "u16", default = "1" } },
            },
          },
        },
        {
          Currency = {
            fields = {
              { amount = { type = "i32", range = { 1, 999999 } } },
            },
          },
        },
      },
    },
  },
  tables = {
    Item = {
      mode = "map",
      key = "id",
      source = { format = "xlsx", file = "Items.xlsx", sheet = "Item" },
      fields = {
        {
          id = {
            type = "u32",
            comment = "Stable item id",
            range = { 1, 999999 },
          },
        },
        { name = { type = "string", length = { 1, 64 } } },
        { rarity = "enum<Rarity>" },
        { bonus = { type = "optional<struct<StatBonus>>", parser = "json" } },
        {
          reward = {
            type = "optional<struct<common.Reward>>",
            parser = "json",
          },
        },
        {
          tags = {
            type = "list<string>",
            parser = { kind = "split", separator = "|" },
          },
        },
      },
      indexes = {
        by_name = { fields = { "name" }, unique = true },
        by_rarity = { "rarity" },
      },
    },
  },
}

Name resolution

In the examples, local declaration Item has the canonical name game.items.Item.

  • enum<Rarity> and ref<Item.id> resolve in the current game.items namespace.
  • struct<common.Reward> expands the common import to game.common.Reward.
  • A dotted name that does not start with an import alias is treated as an absolute project-qualified name.
  • In ref<game.items.Item.id>, the final segment is the field and everything before it is the table name.

Namespace segments, import aliases, and declaration names must be ASCII identifiers. The empty namespace is the project root namespace.

Shared keyed model and ordering

enums, structs, unions, tables, fields, variants, indexes, and localization.sources use declaration names as mapping keys. Unknown properties and the former array-of-named-objects representation are rejected.

Enum values, fields, and union variants preserve author order. Other named collections are normalized by name for deterministic output. Lua must use singleton-table lists for ordered named collections, as shown above; ordinary Lua table key iteration does not preserve declaration order.

Use Sora’s includes array for composition. Native SCON include, substitutions, interpolation, and object or array spread are deliberately rejected so all formats retain identical semantics.

Shorthands

  • An enum can be a string array. Use the detailed { values, groups, aliases, comment } form when needed. Alias mappings are external alias -> canonical value.
  • A field can be a type string. Use the detailed form when it has groups, comment, default, range, length, parser, or table-only from.
  • A parser without options can be a string such as parser = "json". With options it is an object containing kind and string-valued options.
  • A non-unique index can be a field array. Use { fields, unique = true } for a unique index.
  • groups accepts either one group string or a list of group strings.

For the complete property semantics, see Enums, Structs, and Unions, Tables, Types, Cell Parsers, References and Derived Fields, Localization, and Project Configuration.

Tables

Tables are source-backed row collections. A table schema declares the table mode, source location, fields, and optional indexes.

Modes

ModeShapeTypical Use
mapRows keyed by one field.Items, quests, levels, buffs.
listOrdered rows without keyed lookup.Drop entries, weighted pools, ordered steps.
singletonOne row.Global settings, tuning constants.
tables {
  Item {
    mode = "map"
    key = "id"
    fields { id = "i32" }
  }
}

For map tables, key names the table’s primary key field. Sora uses it for row uniqueness, generated lookup APIs, Excel template hints, and ref<Table.key> validation.

Source

source {
  format = "xlsx"
  file = "Core.xlsx"
  sheet = "Item"
}

format can be omitted when the project or command provides a default source format. file is resolved under the command’s --data-root during export and validation.

Built-in source formats are xlsx, csv, toml, json, and yaml. JSON and YAML table files are arrays of row objects:

[
  { "id": 1001, "name": "Iron Sword" },
  { "id": 1002, "name": "Health Potion" }
]

For JSON and YAML, file can also point to a directory. In that case Sora recursively reads every matching .json, .yaml, or .yml file as one row object, sorted by path.

Split one XLSX table across sheets

An XLSX-backed table can combine several worksheets into one logical table. This is useful for data that is naturally partitioned for editing, such as one activity sheet per month:

An XLSX workbook belongs to exactly one table definition. Multiple tables cannot share the same source.file; use separate workbook files for separate schemas. Multiple sheets in that file are partitions of its one table, not separate table definitions.

source {
  format = "xlsx"
  file = "Activity.xlsx"
  sheets = ["2026-01", "2026-02", "2026-03"]
}

Every selected worksheet uses the same columns and parsers declared by the table schema. Sora concatenates their rows, then validates and exports the combined result as one table. Primary-key, unique-index, reference, and singleton checks therefore apply across all selected sheets, not separately per sheet.

Selectors may contain * (any sequence) and ? (one character):

source {
  format = "xlsx"
  file = "Activity.xlsx"
  sheets = ["2026-*", "special"]
}

Explicit sheet names keep declaration order. Matches from each wildcard selector are sorted by sheet name. Overlapping selectors include a worksheet only once. Loading fails when an explicit worksheet is missing or a wildcard matches nothing.

sheets is supported only for XLSX sources and cannot be combined with sheet. New template generation requires explicit sheet names because a wildcard cannot invent worksheets; wildcard selectors can be used with an existing workbook. Excel template generation and sync create missing explicitly named worksheets, and sync preserves rows in each selected worksheet. Row-level Studio data mutation currently rejects multi-sheet tables because merged rows do not retain worksheet ownership; schema editing, loading, validation, and export remain supported.

Indexes

Indexes are extra lookup paths on a table. They are different from the key of a mode = "map" table:

ConceptPurpose
table keyThe primary key. A map table uses it to keep rows unique and to generate the main get(id) lookup.
table indexesAdditional keyed lookup paths, such as lookup by name, grouping by type, or finding drops by stage.

For example, an Item table can use id as its primary key:

tables {
  Item {
    mode = "map"
    key = "id"
    fields {
      id = "i32"
      name = "string"
      item_type = "enum<ItemType>"
    }
  }
}

Add a unique index when another field should also identify at most one row:

indexes {
  by_name {
    fields = ["name"]
    unique = true
  }
}

Example data:

idnameitem_type
1001Iron SwordWeapon
1002Wood ShieldArmor

unique = true means name cannot repeat. Generated code for targets that support the index can expose a helper similar to get_by_name("Iron Sword"), returning one row or no row.

Use a non-unique index when a key can match many rows:

indexes {
  by_item_type = ["item_type"]
}

Example data:

idnameitem_type
1001Iron SwordWeapon
1002Bronze AxeWeapon
2001Wood ShieldArmor

unique = false means one key can match several rows. Generated code for targets that support the index can expose a helper similar to get_by_item_type(ItemType::Weapon), returning the matching rows.

fields is a list, so a unique index can also express combined uniqueness:

indexes {
  by_world_stage {
    fields = ["world", "stage"]
    unique = true
  }
}

This requires each (world, stage) pair to be unique. For example, (1, 1) can appear once, while (1, 2) is a different key. Current generated lookup helpers mainly support single-field indexes on non-singleton tables; combined indexes are most useful for validation today.

Validation

Sora validates table rows after loading source data:

  • non-optional fields must be present unless a default exists;
  • key fields must be unique for map tables;
  • enum values must be valid;
  • references must point to existing rows;
  • numeric ranges and length ranges must pass;
  • parser output must match the declared field type.

Types

Sora type expressions are written as strings in schema fields.

Primitive Types

TypeMeaning
boolBoolean value.
i88-bit signed integer.
u88-bit unsigned integer.
i1616-bit signed integer.
u1616-bit unsigned integer.
i3232-bit signed integer.
u3232-bit unsigned integer.
i6464-bit signed integer.
f3232-bit floating point value.
f6464-bit floating point value.
stringUTF-8 string.
durationNon-negative duration written as units such as 500ms, 30s, 15m, 2h, 7d, or 1h 30m. Units must be ordered from largest to smallest: d, h, m, s, ms. Runtime data stores milliseconds.
textLocalization text key. See Localization.

Integer widths are validated by Sora before export. Some target languages do not have unsigned small integer types, so generated code may use a wider signed type while preserving the schema range.

[[tables.fields]]
name = "level"
type = "u16"
range = [1, 100]

Named Types

TypeExample
Enumenum<ItemType>
Structstruct<ResourceCost>
Unionunion<RewardAction>
Referenceref<Item.id>

References must point to the primary key of a mode = "map" table. Containers can wrap references, for example list<ref<Item.id>>.

[[tables.fields]]
name = "item_type"
type = "enum<ItemType>"

[[tables.fields]]
name = "price"
type = "struct<ResourceCost>"
parser = { kind = "tuple" }

Collections

TypeMeaning
list<T>Ordered repeated values.
set<T>Unique repeated values.
array<T,N>Fixed-length repeated values.
map<K,V>Key/value pairs.
optional<T>Nullable or absent value.
[[tables.fields]]
name = "tags"
type = "set<string>"
parser = { kind = "json" }
default = "[\"misc\"]"

[[tables.fields]]
name = "attributes"
type = "map<string,i32>"
parser = { kind = "map" }

Intervals

interval<T> stores one non-empty bounded interval together with the inclusivity of each bound. T can be any integer or floating-point primitive, duration, or datetime.

[[tables.fields]]
name = "recommended_level"
type = "interval<i32>"
default = "[1,10]"

Excel, CSV, and string defaults use standard mathematical interval notation. [ and ] include a bound; ( and ) exclude it:

ValueMeaning
[1,10]1 <= x <= 10
(1,10]1 < x <= 10
[1,10)1 <= x < 10
(1,10)1 < x < 10

Both bounds are required. Sora rejects a start greater than the end. Equal bounds are valid only when both are inclusive, because every other equal-bound combination is empty.

Structured TOML, JSON, and YAML data uses the canonical object representation:

recommended_level = { start = 1, end = 10, start_inclusive = false, end_inclusive = true }

Generated APIs preserve these four fields and add boundary-aware helpers, so business code can call contains and isSingleton/is_singleton instead of repeating open/closed-bound checks. Rust, C++, C#, TypeScript, Python, and Scala use an idiomatic generic SoraInterval<T> (or the language’s bracket form). Java, Kotlin, Go, Dart, Godot, and C emit concrete types such as SoraIntervalI32; JavaScript, Lua, and Erlang provide a shared runtime interval helper. The binary and structured wire shapes remain unchanged. Collections of intervals are supported; use JSON cell input for those collections.

Cell Examples

These examples show what a designer would put in an Excel or CSV cell:

Field typeParserCell value
u16none1001
enum<ItemType>noneWeapon
list<i32>none or split1,2,3
durationnone1h 30m
interval<i32>none(1,100]
interval<duration>none[500ms,2s)
textnonequest.1001.title
set<string>json["starter","melee"]
struct<ResourceCost>tupleGold,0,100
struct<ResourceCost>columnsspread across cost_kind, cost_id, cost_count columns
map<string,i32>mapatk,10|hp,20
union<EventCondition>json{"type":"QuestCompleted","quest_id":5002}
optional<ref<Item.id>>noneempty cell or 1001

Field Rules

[[tables.fields]], [[structs.fields]], and [[unions.variants.fields]] share the common field properties. Table fields have extra table-only properties for derived values; those properties are invalid on struct fields and union variant fields. A table primary key is declared once on the table itself with key = "field_name".

Field presence is part of the type: optional<T> means the value may be absent or null, while every other type is required unless a default fills the missing value.

For TOML/JSON/YAML-style object inputs, a field can be absent from the object. For Excel and CSV, the column must exist in the header; an omitted cell, blank cell, or short CSV record is treated as an empty cell.

Schema fieldObject field absentExcel/CSV cell empty
type = "i32"Validation error.Validation error.
type = "optional<i32>"null.null.
type = "i32" plus default = "1"1.1.
type = "optional<i32>" plus default = "1"1.null.
PropertyApplies ToPurpose
nameall fieldsField name used in source data, validation errors, generated code, and exported runtime data.
typeall fieldsType expression such as i32, struct<ResourceCost>, or list<union<RewardAction>>.
defaultall fields except derived fieldsString value used when the source object field is absent or a required Excel/CSV cell is empty.
commentall fieldsDescription used in generated Excel headers.
rangenumeric fields, duration, and collection elements of those typesInclusive numeric range, written as [min, max]. Duration ranges are milliseconds.
lengthstring, list, set, array, mapInclusive length range, written as [min, max].
parsercell-based inputs and defaultsCell parser hint. See Cell Parsers.
groupsall fieldsDeclared groups that contain the field. Omitted fields inherit the project’s default groups.
fromtable fields onlyOptional child-table source for a derived field.

Defaults are written as strings because they are parsed through the same type-aware conversion path as source data.

from describes a field derived from matching rows in another table; see References and Derived Fields. Derived fields can be list<T>, T, or optional<T> and cannot declare default.

Enums, Structs, and Unions

These definitions let schemas model more than flat tables.

Enums

enums {
  Rarity = ["Common", "Uncommon", "Rare", "Epic", "Legendary"]
}

Enums keep source data readable while generated code receives a constrained type.

Use the detailed form to document the enum and individual values. Sora preserves these comments in the schema lock and emits the target language’s documentation comments in generated code:

enums {
  Rarity {
    comment = "Item rarity"
    values = [
      { id = 0, name = "Common", comment = "A commonly found item" },
      { id = 1, name = "Rare", comment = "A hard-to-find item" },
    ]
  }
}

Aliases can keep imported or legacy names readable:

enums {
  Rarity {
    values = ["Common", "Uncommon", "Rare", "Epic", "Legendary"]
    aliases { Purple = "Epic" }
  }
}

Structs

structs {
  ResourceCost {
    fields {
      kind = "enum<ResourceKind>"
      id = "i32"
      count {
        type = "i32"
        range = [1, 999999]
      }
    }
  }
}

Use structs for nested values that appear in many places. A field can reference a struct with type = "struct<ResourceCost>".

Struct fields use the same field properties as table fields, including type, default, comment, range, length, parser, and groups. The field name comes from its key. Table-specific properties such as key and from are not meaningful for normal struct fields. See Types for the full field reference.

In cell-based inputs, a struct field can be written as JSON object text by default:

{"kind":"Gold","id":0,"count":100}

For compact cells, declare parser = { kind = "tuple" } on the field that references the struct. Tuple values follow the struct field order:

Gold,0,100

Unions

Use a union when one field can contain different shapes. For example, an event condition might be either “quest completed” or “player has item”:

{"type":"QuestCompleted","quest_id":5002}
{"type":"HasItem","item_id":1001,"count":2}

The type value selects which variant is present. The rest of the fields depend on that variant.

unions {
  RewardAction {
    tag = "type"
    variants {
      AddItem {
        fields {
          item_id = "ref<Item.id>"
          count = "i32"
        }
      }
      UnlockStage {
        fields { stage_id = "ref<Stage.id>" }
      }
    }
  }
}

Use unions when a field can contain one of several tagged shapes. Examples include conditions, rewards, triggers, and scripted actions.

The union tag defaults to type if omitted. Source data must include that tag with the variant name. The remaining fields must match the selected variant; unknown fields and missing non-optional variant fields are validation errors.

The most direct Excel or CSV form is JSON object text in one cell:

Field typeCell value
union<RewardAction>{"type":"AddItem","item_id":1001,"count":2}

For a list of union values, declare parser = { kind = "json" } and write a JSON array:

fields {
  actions { type = "list<union<RewardAction>>" parser = "json" }
}
[
  {"type":"AddItem","item_id":1001,"count":2},
  {"type":"UnlockStage","stage_id":9002}
]

If you do not want JSON in Excel or CSV cells, a single union<T> field can be expanded into several columns. This action field is one union value:

fields {
  action { type = "union<RewardAction>" parser = "tagged_columns" }
}

The Excel sheet then has columns like this:

ABCDEF
idnameaction.typeaction.item_idaction.countaction.stage_id
1Give SwordAddItem10012
2Open StageUnlockStage9002

action.type contains the variant name. An AddItem row fills only item_id and count; an UnlockStage row fills only stage_id. Columns for other variants stay empty.

tagged_columns is only valid on a field whose type is exactly union<T>; it cannot be applied directly to list<union<T>>. When a parent field needs several union values, put each union value in a child row and derive the parent list from that child table:

tables {
  EventRule {
    fields {
      actions {
        type = "list<union<RewardAction>>"
        from = { table = "EventActionEntry", parent_key = "id", child_key = "event_id", field = "value", order_by = "seq" }
      }
    }
  }
  EventActionEntry {
    mode = "list"
    fields {
      event_id = "ref<EventRule.id>"
      seq = "i32"
      value {
        type = "union<RewardAction>"
        parser = { kind = "tagged_columns", prefix = "" }
      }
    }
  }
}

The parent EventRule sheet keeps ordinary columns:

AB
idname
1First Event

The child EventActionEntry sheet stores one action per row:

ABCDEF
event_idseqtypeitem_idcountstage_id
11AddItem10012
12UnlockStage9002

On export, EventRule.actions receives two union values ordered by seq. The prefix = "" option makes the child table columns use plain names such as type, item_id, count, and stage_id; do not use an empty prefix if those names conflict with other fields on the same table.

See Cell Parsers for the exact column rules.

In TOML data files, unions can be written as normal nested tables:

[[rows]]
id = 1
condition = { type = "QuestCompleted", quest_id = 5002 }
actions = [
  { type = "AddItem", item_id = 1001, count = 2 },
  { type = "UnlockStage", stage_id = 9002 },
]

References and Derived Fields

References let one table point to another table’s primary key. Derived fields copy or assemble data from matching rows in another table.

FeatureWhat source data storesWhat runtime model gets
ref<Item.id>The target row id, such as 1001.The id value or a target-specific wrapper.
from = { ... }Rows stay in a child table.The parent row receives a copied/nested value.

Use ref when the relationship itself should remain an id. Use from when exported data should contain a convenient nested field.

The target of a ref must be a mode = "map" table, and the referenced field must be that table’s key.

References

[[tables.fields]]
name = "required_item"
type = "ref<Item.id>"

Sora validates that every value points to an existing row in the referenced table.

References are still stored as values in source data. The generated runtime can expose them as key values or target-specific wrapper types depending on the language backend.

References can be nested in containers such as list<ref<Item.id>>, set<ref<Item.id>>, or optional<ref<Item.id>>. The same primary-key rule applies to the inner ref.

Rust strong table keys

Rust code generation gives every primitive map table key a table-owned nominal newtype. A table Item keyed by id: u32 generates ItemId; both Item.id and every ref<Item.id> use that exact type, including references nested inside containers, structs, and unions. Another u32-keyed table receives a different Rust type, so its id cannot be passed accidentally.

The wrapper is #[repr(transparent)] and uses transparent Serde, so bundles, JSON, schema locks, and other data formats still store the original scalar. It exposes explicit from_raw and raw/as_str accessors without exposing the tuple field or implementing Deref to the primitive. String and text key tables also provide an explicit get_str(&str) lookup helper.

Enum primary keys use the generated enum directly. If a table key is itself ref<Other.id>, it reuses Other’s final key type rather than adding another wrapper. Qualified table names keep the key type in the owning table module; Sora does not create a root re-export.

This is the standard Rust representation and has no compatibility switch. Other language generators currently retain their existing key representation.

Derived Fields

A derived field is not read from the current table’s cell. It is built from matching rows in another table.

This keeps editable data normalized while generated runtime models can expose convenient nested values. For example, quest rewards can be stored as two tables:

Quest:

idname
1001First Quest
1002Second Quest

QuestReward:

quest_idsort_orderitem_idcount
10011200110
1001220021
1002120035

At runtime, Quest may want a direct rewards: list<Reward> field. Declare that the field comes from QuestReward:

[[structs]]
name = "Reward"

[[structs.fields]]
name = "item_id"
type = "ref<Item.id>"

[[structs.fields]]
name = "count"
type = "i32"

[[tables]]
id = "quest"
name = "Quest"
mode = "map"
key = "id"

[[tables.fields]]
name = "id"
type = "i32"

[[tables.fields]]
name = "name"
type = "string"

[[tables.fields]]
name = "rewards"
type = "list<struct<Reward>>"
from = { table = "QuestReward", parent_key = "id", child_key = "quest_id", order_by = "sort_order" }

[[tables]]
id = "questreward"
name = "QuestReward"
mode = "list"

[[tables.fields]]
name = "quest_id"
type = "ref<Quest.id>"

[[tables.fields]]
name = "sort_order"
type = "i32"

[[tables.fields]]
name = "item_id"
type = "ref<Item.id>"

[[tables.fields]]
name = "count"
type = "i32"

This means:

  • from.table = "QuestReward": read matching rows from the QuestReward child table.
  • from.parent_key = "id": use the parent row’s Quest.id value for matching.
  • from.child_key = "quest_id": match child rows where QuestReward.quest_id equals the parent key.
  • from.order_by = "sort_order": when several child rows match, sort them by the child table’s sort_order field in ascending order.

With the example data above, Quest.id = 1001 receives two reward rows, ordered as 2001, then 2002.

The exported parent row is shaped as if rewards had been written directly on Quest:

{
  "id": 1001,
  "name": "First Quest",
  "rewards": [
    {"item_id": 2001, "count": 10},
    {"item_id": 2002, "count": 1}
  ]
}

The field type controls how many child rows may match:

Field typeMatch countResult when no row matches
list<T>zero or moreempty list
optional<T>zero or onenull
Texactly onevalidation error
map<K,V>zero or more, with unique keysempty map

If T or optional<T> matches more than one child row, Sora reports an error.

Deriving a Map

A derived map uses the same parent/child join as a derived list. Add from.key to select the child field that becomes each map entry’s key, and use from.field to select the value:

tables {
  Item {
    mode = "map"
    key = "id"
    fields {
      id = "i32"
      drop_rates {
        type = "map<string,i32>"
        from {
          table = "ItemDropRate"
          parent_key = "id"
          child_key = "item_id"
          key = "rarity"
          field = "rate"
        }
      }
    }
  }

  ItemDropRate {
    mode = "list"
    fields {
      item_id = "ref<Item.id>"
      rarity = "string"
      rate = "i32"
    }
  }
}

Editable rows stay simple and need no synthetic child-row id:

item_idrarityrate
1001Common80
1001Epic20

For Item.id = 1001, Sora materializes:

"drop_rates": [["Common", 80], ["Epic", 20]]

Sora uses pair arrays for maps so non-string key types remain unambiguous. Generated runtimes expose the target language’s normal map or dictionary type.

The rules are deliberately narrow:

  • An exact map<K,V> derived field must declare from.key; from.key is invalid on non-map fields.
  • The child key field must exist and its type must exactly match K. Sora resolves ref<Table.field> to the referenced field type before comparing; it performs no implicit conversion.
  • from.field, when present, must be type-compatible with V.
  • Without from.field, V must be a struct assembled from same-named child fields.
  • Duplicate map keys among rows matched to the same parent are an error. Enum aliases count as their canonical enum value for duplicate detection.
  • No matching child rows produce an empty map.
  • order_by may control deterministic pair order, although map lookup semantics do not depend on order.

Studio derives reverse usages from the existing from relationships. A source table can therefore show entries such as Item.drop_rates (item_id -> id, key=rarity, field=rate) without adding a second ownership declaration to the schema. One source table may still feed several parent fields or tables.

Copying One Child Field

Without from.field, Sora assembles a struct from child table fields with the same names as the struct fields.

When the parent should receive one field from the child row instead, set from.field:

[[unions]]
name = "EventCondition"
tag = "type"

[[unions.variants]]
name = "QuestCompleted"

[[unions.variants.fields]]
name = "quest_id"
type = "ref<Quest.id>"

[[unions.variants]]
name = "HasItem"

[[unions.variants.fields]]
name = "item_id"
type = "ref<Item.id>"

[[unions.variants.fields]]
name = "count"
type = "i32"

[[tables.fields]]
name = "condition"
type = "union<EventCondition>"
from = { table = "EventConditionEntry", parent_key = "id", child_key = "event_id", field = "value" }

[[tables]]
id = "eventconditionentry"
name = "EventConditionEntry"
mode = "list"

[[tables.fields]]
name = "event_id"
type = "ref<Event.id>"

[[tables.fields]]
name = "value"
type = "union<EventCondition>"
parser = { kind = "tagged_columns", prefix = "" }

This means Event.condition receives EventConditionEntry.value for the child row whose event_id matches Event.id. The child table may still contain helper columns such as id, event_id, notes, or sort fields; only the value field named by from.field is copied into the parent field.

In Excel, EventConditionEntry can look like this:

ABCDE
event_idtypequest_iditem_idcount
1QuestCompleted5002
2HasItem10012

From Options

The from object has these options:

OptionRequiredMeaning
tableyesChild table name. Sora scans this table for matching rows.
parent_keyyesField name on the parent table. Each parent row uses this field value for matching.
child_keyyesField name on the child table. A child row is selected when this value equals the parent key.
keyfor map<K,V>Child field copied into each map entry’s key. Invalid for non-map derived fields.
fieldnoField name on the child table. When present, Sora copies this field’s value instead of assembling a struct from the child row.
order_bynoField name on the child table. When present, matched child rows are sorted by this field in ascending order.

order_by is a field name, not an expression. There is no desc, multi-field ordering, filtering, or custom sort syntax. If order_by is omitted, matched rows keep the source table read order.

The order_by field must exist on the child table. It is usually an i32 ordering field such as sort_order, seq, or rank. Sorting is ascending.

Without from.field, the derived value type must be a struct: list<struct<...>>, struct<...>, optional<struct<...>>, or map<K,struct<...>>. Struct fields are copied from child table fields with the same names:

[[structs]]
name = "Reward"

[[structs.fields]]
name = "item_id"
type = "ref<Item.id>"

[[structs.fields]]
name = "count"
type = "i32"

Here Reward.item_id and Reward.count must both exist as compatible fields on QuestReward.

With from.field, the derived value type must be compatible with that child field. For example, type = "union<EventCondition>" can derive from a child field value whose type is also union<EventCondition>.

A derived field cannot also declare default. Its value comes from matched child rows.

Multiple Derived Fields from One Child Table

Several parent tables can derive fields from the same child table. This does not consume or move child rows. It reads the child table and copies matching values into each parent field.

For example, both Quest and QuestPreview can receive rewards from QuestReward:

[[tables]]
id = "quest"
name = "Quest"
mode = "map"
key = "id"

[[tables.fields]]
name = "rewards"
type = "list<struct<Reward>>"
from = { table = "QuestReward", parent_key = "id", child_key = "quest_id", order_by = "sort_order" }

[[tables]]
id = "questpreview"
name = "QuestPreview"
mode = "map"
key = "id"

[[tables.fields]]
name = "rewards"
type = "list<struct<Reward>>"
from = { table = "QuestReward", parent_key = "id", child_key = "quest_id", order_by = "sort_order" }

If both Quest.id = 1001 and QuestPreview.id = 1001 exist, both parent rows receive the reward list from QuestReward.quest_id = 1001. Sora does not mark the child row as already used by Quest, and it does not remove the row from QuestReward.

Cell Parsers

Parsers are only for cell-based inputs such as Excel and CSV. Most parsers tell Sora how to turn one cell into a typed value; projection parsers such as columns and tagged_columns tell Sora how one field maps to several input columns. String default values use the same parser path for single-cell parsers. TOML row data can usually use native TOML arrays and tables instead.

Use a parser when the default cell format is too verbose or ambiguous:

[[tables.fields]]
name = "tags"
type = "list<string>"
parser = { kind = "split", separator = "|" }

With that schema, the cell value is:

starter|melee|weapon

Parser options are string values. Unknown parser kinds, unsupported options, and empty option values fail during schema normalization. The exception is projection prefixes such as columns.prefix and tagged_columns.prefix, where "" is meaningful.

Custom Lua Parsers

Projects can load project-local Lua parser scripts from project.scon:

[parsers]
scripts = ["tools/parsers.lua"]

Script paths are resolved relative to the project file. After that, every command that reads the project can use the custom parsers without repeating command-line flags:

sora build --project project.scon
sora export --project project.scon --data-root data --format json --out generated/config.json

CLI commands can also load temporary parser scripts with the global --parser-script option:

sora --parser-script tools/parsers.lua build --project project.scon
sora --parser-script tools/parsers.lua export --project project.scon --data-root data --format json --out generated/config.json

The option can be repeated and is appended after project-configured scripts. Custom parsers are trusted project code. Sora loads them with a limited Lua standard library and does not expose io, os, package, or debug.

A parser script returns a table with parsers. Each parser must define parse(cell, ctx). options is the list of supported parser options. validate(field) is optional and runs during schema normalization.

return {
  parsers = {
    slug = {
      options = { "prefix" },
      validate = function(field)
        if field.type ~= "string" then
          error("slug parser requires string")
        end
      end,
      parse = function(cell, ctx)
        local text = string.lower(string.gsub(cell.text, "%s+", "-"))
        if ctx.options.prefix ~= nil then
          return ctx.options.prefix .. text
        end
        return text
      end,
    },
  },
}

Schema fields use the custom parser by name:

[[tables.fields]]
name = "tag"
type = "string"
parser = { kind = "slug", prefix = "item-" }

cell contains kind, text, and value where applicable. ctx contains field, type, options, path, and location fields such as row, column, and sheet for worksheets. Lua return values map to Sora data values: nil, booleans, integers, floats, strings, array-like tables, and string-keyed tables.

Custom Lua parsers are single-cell parsers. They do not replace projection parsers such as columns or tagged_columns, cannot read neighboring cells, and do not change schema, source loading, or generated runtime behavior.

Default Parsing

If a field has no parser, Sora uses type-aware default parsing:

TypeCell format
boolBoolean cells, true, false, or numeric cells where zero is false and non-zero is true.
i32, i64Integer cells, integer text, or whole-number float cells.
ref<Table.key>Parsed using the referenced primary key’s type. For example, a ref to a string key accepts text cells.
durationDuration text using d, h, m, s, or ms, for example 500ms, 30s, or 1h 30m. Units must be ordered from largest to smallest.
f32, f64Numeric cells or numeric text.
string, enum<Name>Cell display text.
struct<Name>, union<Name>JSON object text.
list<T>, set<T>, array<T,N>Comma-separated text. Use json for JSON arrays.
map<K,V>JSON array of two-item pairs, for example [["atk",10],["hp",20]].
optional<T>Empty cell becomes null; otherwise the inner T is parsed.

Default collection parsing is intentionally simple. Primitive items are parsed by type. Struct and union collection items must be JSON object text. Nested collections cannot be represented safely with one separator; use parser = { kind = "json" }.

Parser Summary

ParserValid target typesCell shape
splitlist<T>, set<T>, array<T,N>, or optional around those typesa,b,c
tuplestruct<T> or optional<struct<T>>Gold,0,100
columnsstruct<T> or optional<struct<T>>Multiple columns
tuple_listlist<struct<T>>, set<struct<T>>, array<struct<T>,N>, or optional around those typesGold,0,100|Gem,0,5
mapmap<K,V> or optional<map<K,V>>atk,10|hp,20
tagged_columnsunion<T> onlyMultiple columns
jsonAny typeJSON value matching the field type

array<T,N> checks the parsed item count. tuple checks the value count against the referenced struct’s field count.

split

Use split for a flat collection of primitive values, enums, refs, or simple values that can be separated reliably.

[[tables.fields]]
name = "starter_items"
type = "list<ref<Item.id>>"
parser = { kind = "split" }

Cell:

1001,1002,1003

Parsed value:

[1001,1002,1003]

Use separator when comma is not a good separator:

[[tables.fields]]
name = "tags"
type = "set<string>"
parser = { kind = "split", separator = "|" }

Cell:

starter|melee|weapon

tuple

Use tuple when a single struct is small enough to fit naturally in one cell. Values follow the referenced struct’s field declaration order.

structs {
  ResourceCost {
    fields { kind = "enum<ResourceKind>" id = "i32" count = "i32" }
  }
}
fields {
  price { type = "struct<ResourceCost>" parser = "tuple" }
}

Cell:

Gold,0,100

Parsed value:

{"kind":"Gold","id":0,"count":100}

Use separator if struct values themselves commonly contain commas:

parser = { kind = "tuple", separator = "|" }

Cell:

Gold|0|100

columns

Use columns when one struct should be edited as normal Excel or CSV columns instead of as JSON or one compact tuple cell. It is valid on struct<T> and optional<struct<T>> table fields.

structs {
  ResourceCost {
    fields { kind = "enum<ResourceKind>" id = "i32" count = "i32" }
  }
}
fields {
  price { type = "struct<ResourceCost>" parser = { kind = "columns", prefix = "price_" } }
}

CSV headers and row:

id,name,price_kind,price_id,price_count
1,Iron Sword,Gold,0,100

Parsed price value:

{"kind":"Gold","id":0,"count":100}

With the default prefix, a field named price projects columns such as price.kind, price.id, and price.count. Use prefix = "" only when the struct field names should live at the table’s top level. Sora rejects projected column name conflicts.

columns does not recursively project nested structs or unions. If a projected struct field is itself complex, either give that child field a single-cell parser such as tuple, split, map, or json, or move the nested data into a dedicated table and connect it with ref or a derived field. This keeps the spreadsheet narrow and keeps complex records reusable.

For generated XLSX templates, columns projected from the same columns field share the same header color.

tuple_list

Use tuple_list for a list of small structs. separator splits fields inside one struct item. item_separator splits items in the list.

[[tables.fields]]
name = "materials"
type = "list<struct<ResourceCost>>"
parser = { kind = "tuple_list" }

Cell:

Item,2003,4|Gold,0,1000

Parsed value:

[
  {"kind":"Item","id":2003,"count":4},
  {"kind":"Gold","id":0,"count":1000}
]

Custom separators:

parser = { kind = "tuple_list", separator = ":", item_separator = ";" }

Cell:

Item:2003:4;Gold:0:1000

map

Use map when a map is simple enough to write as repeated key/value pairs. separator splits key from value. item_separator splits map entries.

[[tables.fields]]
name = "attributes"
type = "map<string,i32>"
parser = { kind = "map" }

Cell:

atk,10|hp,20

Parsed value:

[["atk",10],["hp",20]]

Sora exports maps as pair arrays so non-string keys remain unambiguous. If you prefer JSON cell syntax, use parser = { kind = "json" } and write the same pair-array shape:

[["atk",10],["hp",20]]

tagged_columns

Use tagged_columns when one union<T> value should be edited across multiple Excel or CSV columns. It is only valid on a table field whose type is exactly union<T>. It is intentionally not valid for optional<union<T>>, list<union<T>>, set<union<T>>, or other containers.

unions {
  EventCondition {
    variants {
      QuestCompleted { fields { quest_id = "ref<Quest.id>" } }
      HasItem { fields { item_id = "ref<Item.id>" count = "i32" } }
    }
  }
}
fields {
  value { type = "union<EventCondition>" parser = { kind = "tagged_columns", prefix = "" } }
}

CSV headers and rows:

id,type,quest_id,item_id,count
1,QuestCompleted,5002,,
2,HasItem,,1001,2

The tag column contains the union variant name. Only fields for the selected variant may contain values. With the default prefix, a field named condition projects columns such as condition.type, condition.quest_id, and condition.item_id. Use prefix = "" only when the projected columns should live at the table’s top level.

Sora rejects projected column name conflicts, for example a normal table field named type plus prefix = "" for a union whose tag is also type.

tagged_columns also does not recursively project nested structs or nested unions inside variant fields. Variant fields can still use single-cell parsers such as tuple, split, map, or json. If a variant needs a large nested object or repeated nested objects, model that data as a dedicated table and reference or derive it instead of widening the union row.

For generated XLSX templates, columns projected from the same tagged_columns field share the same header color. The tag column uses the same color group with stronger emphasis.

json

Use json for nested values, unions inside containers, nested collections, and any shape that needs explicit escaping.

fields {
  actions { type = "list<union<RewardAction>>" parser = "json" }
}

Cell:

[
  {"type":"AddItem","item_id":1007,"count":3},
  {"type":"UnlockStage","stage_id":9002}
]

For one union value:

[[tables.fields]]
name = "condition"
type = "union<EventCondition>"
parser = { kind = "json" }

Cell:

{"type":"QuestCompleted","quest_id":5002}

For map<K,V>, JSON uses an array of pairs, not a JSON object:

[["atk",10],["hp",20]]

Choosing a Parser

NeedPrefer
Flat list of primitive valuessplit
One compact structtuple
One struct spread across columnscolumns
Repeated compact structstuple_list
Simple key/value pairsmap
One union spread across columnstagged_columns
Nested values, unions in containers, escaping, or JSON-shaped cellsjson

Project Config

The project manifest can be used as a simple schema root or as a full build description. It can be written as SCON, TOML, YAML, JSON, or Lua; SCON is recommended and used below.

project { id = "game_config" }
groups { common { default = true } }
views {
  default {
    contract = "game_config/default"
    groups = ["common"]
  }
}
includes = ["schema/items.scon"]

parsers { scripts = ["tools/parsers.lua"] }
type_mappings { scripts = ["tools/type_mappings.lua"] }

build {
  default_source_format = "xlsx"
  data_root = "data"
  view = "default"
  schema_lock = "generated/schema.lock"
  excel_templates = "generated/excel"
  codegen = [
    { target = "rust", out = "rust/src/generated", format = "auto" },
  ]
  exports = [
    { format = "binary", out = "generated/config.sora" },
  ]
}

Run every configured output:

sora build --project project.scon

data_root and excel_templates serve different purposes. data_root is the input directory used by export and build, so it contains edited table rows. excel_templates is an output directory for generated workbook templates, so it can be deleted and regenerated after schema changes. Do not point excel_templates at your edited data directory unless replacing those workbooks is intentional.

parsers.scripts lists custom Lua cell parser scripts used by CLI commands that read the project. Paths are relative to the project file. See Cell Parsers for the script API.

type_mappings.scripts lists Lua scripts that customize generated language types. Paths are relative to the project file. Type mappings are codegen-only: the schema still uses language-neutral Sora types such as struct<Vec3>, while the mapping script can map that named type to a target-specific type.

Localization is declared at the project root with localization. Its keyed sources are independent from normal tables; see Localization.

Run one configured codegen target:

sora build --project project.scon --target rust

Target Options

Language-specific options live under [codegen.<target>]:

[codegen.rust]
runtime_format = "sora"

[codegen.typescript]
runtime_format = "json"
enum_repr = "string"

[codegen.lua]
runtime_format = "cbor"
lua_version = "5.4"

These options are consumed by the selected generator. The normalized IR stays language-neutral.

Type mapping scripts return a table with type_mappings. Each mapping targets one language and one named schema type:

return {
  type_mappings = {
    {
      target = "csharp",
      schema_type = "Vec3",
      type_name = "Vector3",
      nullable_type_name = "Vector3?",
      decode = "GameMappings.ToVector3({value})",
      value_decode = "GameMappings.ToVector3({value})",
      imports = { "UnityEngine" },
    },
  },
}

nullable_type_name is optional. Use it when optional<schema_type> needs a different target-language type expression from the backend’s default nullable wrapper.

decode wraps the normal binary runtime decode expression, and value_decode wraps JSON/CBOR/protobuf-style value decode. The {value} placeholder is replaced with the generated default expression.

The C target uses write-into decode functions, so C mappings should use decode_into instead of decode. The {target} placeholder is replaced with the output pointer expression. C mappings can also provide free, where {target} is replaced with the pointer that should be released:

{
  target = "c",
  schema_type = "Vec3",
  type_name = "game_vector3",
  decode_into = "game_vector3_decode(reader, {target})",
  free = "game_vector3_free({target});",
  imports = { "#include \"vector3.h\"" },
}

imports is target-specific and is only emitted by language generators that need it. C#, Java, Kotlin, and Scala expect an import namespace/path without the leading keyword. Go expects an import spec such as "example.com/game/vector". Python, TypeScript, JavaScript, Dart, Godot, C, C++, and Rust expect a complete import/include/use/preload line.

runtime_format can be sora, json, cbor, or sora-protobuf, but not every target supports every runtime format. See Runtime Formats for the support matrix.

Built-In Target Options

TargetOptions
rustruntime_format default sora; map_type = "std" or "fx_hash_map" default std; string_storage = "owned" or "arc" default owned; optional crate emits a standalone library crate.
kotlinruntime_format default sora.
csharpruntime_format default sora.
javaruntime_format default sora; nullable_annotation defaults to SoraNullable, set an annotation class such as org.jetbrains.annotations.Nullable, or set "" to disable annotations.
scalaruntime_format default sora; scala_version = "2.12", "2.13", or "3" default 3.
goruntime_format default sora.
dartruntime_format = "json", "cbor", or "sora-protobuf". Set this explicitly; sora is not supported for Dart.
godotruntime_format = "json". Set this explicitly; it is the only supported Godot runtime format.
cruntime_format = "sora"; c_standard = "c99", "c11", "c17", or "c23" default c11; prefix optional symbol prefix.
cppruntime_format = "sora"; cpp_standard = "c++11", "c++14", "c++17", "c++20", or "c++23" default c++17; namespace optional C++ namespace.
typescriptruntime_format default sora; enum_repr = "string" or "integer" default string.
javascriptruntime_format default sora; enum_repr = "string" or "integer" default string; emit_dts boolean default true.
erlangruntime_format default sora; enum_repr = "atom" or "integer" default atom.
luaruntime_format default sora; module optional require/import prefix; lua_version = "5.1", "5.2", "5.3", "5.4", or "luajit" default 5.4; enum_repr = "string" or "integer" default string.
pythonruntime_format default sora.
proto-schemaNo target options. Generates .proto schema files instead of a runtime loader.

Example with several language-specific options:

[codegen.rust]
runtime_format = "sora"
map_type = "fx_hash_map"
string_storage = "arc"

[codegen.cpp]
runtime_format = "sora"
cpp_standard = "c++20"
namespace = "game::config"

[codegen.javascript]
runtime_format = "json"
enum_repr = "integer"
emit_dts = true

Standalone Rust Crate

Rust normally writes a module tree directly into the configured out directory. Set codegen.rust.crate to make out the root of a complete library crate instead:

build {
  codegen = [{ target = "rust", out = "generated/game-config" }]
}

codegen {
  rust {
    runtime_format = "sora"
    crate {
      name = "game-config"
      version = "0.1.0"
      edition = "2024"
      publish = false
    }
  }
}

Sora then writes Cargo.toml, src/lib.rs, the generated models, and the runtime under generated/game-config. name is required. version, edition, and publish default to 0.1.0, 2024, and false. The manifest includes the dependencies required by the selected runtime format and Rust options. Applications can consume it with a normal path dependency:

[dependencies]
game-config = { path = "../generated/game-config" }

Without crate, the existing module layout remains unchanged and out should point to the module directory inside the consuming crate.

Localization

Sora treats translated text as a separate locale catalog, not as a normal config table.

Business config stores text keys with the text type. Locale source sheets provide translations for those keys. Runtime code loads the normal config bundle and mounts one or more locale packs separately.

business tables -> config bundle
localization sources -> LocaleCatalog -> i18n locale packs

Text Keys

Use text for fields that point to localized copy:

fields {
  title_key = "text"
  body_keys = "list<text>"
}

text is a key, not the translated text itself. Source data should contain values such as quest.1001.title or ui.confirm. Generated code exposes this as a TextKey where the target language has a distinct generated runtime type.

The catalog validator checks every text value in business data. A missing key or empty translation is a build error.

Catalog Sources

Declare localization at the project schema root:

localization {
  locales = ["zh_cn", "en_us"]
  default_locale = "zh_cn"
  fallback_locale = "en_us"
  sources {
    ui { file = "Core.xlsx" sheet = "UILocalization" }
    quest { file = "Quest.xlsx" sheet = "QuestLocalization" }
  }
}

Each source is a wide table. The default key column is key:

keyzh_cnen_usnote
ui.confirm确认Confirmbutton label
quest.1001.title第一章Chapter Onequest title

Locale columns named in locales are exported into locale packs. Other columns, such as note, are editor-only metadata and are ignored by runtime packs.

Rules:

RuleBehavior
source.nameMust be an ASCII identifier. It is used for diagnostics and organization, not as a key prefix.
key valuesMay use dotted names such as quest.1001.title.
Multiple sourcesAll sources merge into one logical catalog.
Duplicate keysBuild error. Keys are globally unique across all sources.
Missing locale columnBuild error.
Empty translationBuild error.

Use key = "id" on a source if the key column is not named key:

sources {
  ui {
    file = "Core.xlsx"
    sheet = "UILocalization"
    key = "id"
  }
}

Export Locale Packs

Normal exports (binary, json, cbor, sora-protobuf, proto) contain business data and text keys only. They do not include translation text.

Add i18n exports in the build manifest:

build {
  exports = [
    { format = "binary", out = "generated/config.sora" },
    { format = "i18n-binary", out = "generated/i18n/zh_cn.sora-i18n", locale = "zh_cn" },
    { format = "i18n-json", out = "generated/i18n/en_us.json", locale = "en_us" },
  ]
}

Use i18n-binary for production locale packs. Use i18n-json for inspection, external translation handoff, or tests.

Runtime Mounting

Generated runtimes load config and locale packs separately. In Rust:

#![allow(unused)]
fn main() {
let config = SoraConfig::from_bytes(config_bytes)?;
let pack = generated::runtime::LocalePack::from_bytes(locale_bytes)?;

let mut i18n = generated::SoraI18n::new();
i18n.mount(&config, pack)?;
i18n.set_locale("zh_cn")?;

let mail = config.mail_template().get(&1001).unwrap();
let title = i18n.text(&mail.title_key);
let body = i18n.format(&mail.body_key, [("count", 100)])?;
}

Mounting validates:

CheckPurpose
schema_fingerprintPrevents loading a locale pack generated for a different schema.
locale declarationRejects packs for locales not declared in [localization].locales.
text keysRejects packs that miss keys used by this config or contain empty text.
mounted localeset_locale fails until a pack for that locale has been mounted.

Business code does not know which source sheet a key came from. It looks up TextKey values with the mounted i18n runtime.

Sora Studio

Sora Studio is the browser-based schema editor embedded in the sora CLI. It is meant for inspecting and editing project schemas without running a separate frontend server.

Start it with a project file:

sora studio --project project.scon

By default Studio binds to 127.0.0.1:5174 and prints the local URL. Use --host or --port when that address is not suitable:

sora studio --project project.scon --port 5180

What It Edits

Studio loads the project file and every schema module listed in includes. Project files and modules can be SCON, TOML, YAML, JSON, or Lua, and a project can mix those formats. New modules default to the recommended .scon extension.

The editor can update:

  • project ID and schema include list;
  • schema module files, including creating and removing included files;
  • tables, structs, enums, and unions;
  • table fields, struct fields, enum values, and union variants;
  • table mode, primary key, source settings, parser settings, defaults, comments, range and length constraints;
  • reference fields and derived child-table fields.

Studio is a schema editor, not a row-data editor. Excel, CSV, TOML, JSON, and YAML table rows are still edited in their source files and validated by sora check, sora export, or sora build.

Visualization

The main canvas shows schema nodes and their relationships:

  • type edges for fields that use enums, structs, or unions;
  • reference edges for ref<Table> fields;
  • derived edges for child-table fields assembled from another table.

The sidebar can filter schemas by name, shows project summary counts, and groups nodes by kind. Diagnostics are shown in the UI so an invalid schema can be identified from Studio instead of making the whole editor unusable.

Preview and Save

Use preview before saving to review the files Studio will write. Studio renders each changed project or schema file in its own format:

  • .toml files are written as TOML;
  • .yaml and .yml files are written as YAML;
  • .json files are written as pretty JSON;
  • .lua files are written as data-returning Lua tables.

Saving normalizes the touched files through Studio’s renderer. This is intentional: Studio keeps the schema data model stable, but it does not preserve comments, exact whitespace, or hand-written ordering inside the edited files. Review the preview before committing.

Delivery

Release builds embed the Studio frontend assets into the sora binary. End users only need the CLI from GitHub Releases or crates.io; they do not need Node.js or a local Vite server.

For release maintainers, build the frontend before building the CLI binary:

cd apps/studio
npm run build
cd ../..
cargo build -p sora-cli --release

If the embedded assets are missing, sora studio reports that apps/studio needs to be built before the CLI.

Model Context Protocol

Sora supports only MCP 2026-07-28 for AI clients. It exposes the same project, schema, data, Excel, and build services used by the CLI and Studio. Generated files are outputs, never mutation inputs. Clients use server/discover and self-contained per-request protocol metadata, not the legacy initialize handshake.

Install and connect

Build or install the sora binary from this repository:

cargo install --path crates/sora-cli

For a local client, prefer stdio and use absolute paths:

{
  "mcpServers": {
    "sora": {
      "command": "/absolute/path/to/sora",
      "args": [
        "mcp",
        "--project",
        "/absolute/path/to/project.toml"
      ]
    }
  }
}

The server writes only JSON-RPC to stdout. Audit logs go to stderr. A project that declares Lua parser, type-mapping, or source-loader scripts remains untrusted unless the client completes the form Elicitation flow or the local command explicitly includes --trust-project-scripts.

Script digests and execution use the same immutable source snapshot. The manifest’s script declarations are captured with it; opening an authorized snapshot never reloads executable scripts from disk. Changes before approval require a new trust round, and changes after authorization cannot replace the code being opened. An existing session is reused only when its loaded manifest, scripts, and runtime options match the authorized snapshot; otherwise opening fails explicitly.

Clients that provide MCP Roots can omit --project. Use sora_project_list and sora_project_open; clients without form Elicitation must pass both root_id and relative_manifest explicitly.

Client input and retries

Roots, project selection, and script trust use MCP 2026-07-28 multi-round-trip input, on both stdio and HTTP. Sora returns resultType: "input_required"; it never waits for a reverse roots/list or elicitation/create JSON-RPC request. The client fulfils the embedded inputRequests locally, then sends a new tools/call with the same tool, arguments, and capability declarations, the server’s opaque requestState, and only the responses requested in that round. Project opening may require three rounds: roots, selection, then script trust. Without Roots support, only configured server roots are available. Without form Elicitation, selection must be explicit and untrusted scripts remain refused.

For example, a Roots-capable client’s initial request is:

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sora_project_list","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"roots":{}}}}}

Sora returns the following shape (requestState is an illustrative opaque handle):

{"jsonrpc":"2.0","id":1,"result":{"resultType":"input_required","inputRequests":{"roots":{"method":"roots/list"}},"requestState":"7b5779e8-fddd-4d34-8582-415ab87baf3e"}}

The client retries, rather than POSTing a standalone JSON-RPC response:

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sora_project_list","arguments":{},"requestState":"7b5779e8-fddd-4d34-8582-415ab87baf3e","inputResponses":{"roots":{"roots":[{"uri":"file:///work/project","name":"project"}]}},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"roots":{}}}}}

Selection and trust inputs have method elicitation/create, form params, and response values such as {"action":"accept","content":{"root_id":"root-…","relative_manifest":"project.toml"}} and {"action":"accept","content":{"trust_project_scripts":true}}. Each uses its own server-issued input key (project_selection or script_trust) and the latest handle. decline or cancel completes that attempt without opening the project; trying again starts a new request without previous input state.

The rmcp input model and client implementation provide native support: RunningService::call_tool automatically invokes local ClientHandler::list_roots / create_elicitation handlers and retries (default maximum ten rounds). call_tool_once exposes each intermediate response. Clients advertising Tasks must drive these input rounds with call_tool_once, then poll the returned task; the high-level call_tool helper does not poll Tasks.

Input handles are authorization-server-local, single-use, and expire after five minutes. Sora retains at most 64 pending continuations, each with a 256 KiB state limit; client roots are limited to 64. Retries are checked against the original tool, arguments, capabilities, input kind/id, and exact requested content, including script paths and digests. Client roots never enter the shared workspace root registry. A pending input is not a successful tool execution or an accepted task; no worker starts until all required input is complete.

Streamable HTTP

Local HTTP:

sora mcp --transport http --host 127.0.0.1 --port 8000

Connect to http://127.0.0.1:8000/mcp. The default allowed browser Origin is the public URL’s origin. Add each other exact browser origin explicitly:

sora mcp --transport http \
  --host 127.0.0.1 \
  --port 8000 \
  --allowed-origin http://localhost:6274

Non-loopback listeners require HTTPS as the public URL and an OAuth resource server configuration:

sora mcp --transport http \
  --host 0.0.0.0 \
  --port 8443 \
  --public-url https://sora.example.com/mcp \
  --allowed-origin https://client.example.com \
  --oauth-issuer https://id.example.com \
  --oauth-audience https://sora.example.com/mcp \
  --oauth-scope sora:mcp

Sora discovers the issuer’s JWKS through RFC 8414. Use --oauth-jwks-uri only when the deployment requires an explicit endpoint. Access tokens must be asymmetrically signed JWTs with matching iss and aud, a subject, an unexpired exp, a valid optional nbf, and every configured scope. Protected resource metadata is available at /.well-known/oauth-protected-resource.

Streamable HTTP is sessionless. It validates Host and Origin and bounds request size, request rate, concurrency, and retained authorization contexts. Requests carry MCP-Protocol-Version: 2026-07-28, protocol/client capability metadata, and the standard Mcp-Method / Mcp-Name routing headers where applicable. Every request is authorized independently; no MCP session ID grants access. Sora does not implement the obsolete standalone HTTP+SSE transport.

  1. List and open a project.
  2. Read its summary, revision, diagnostics, and normalized schema resources.
  3. Validate schema and data.
  4. For schema, row, project, or Excel changes, call the matching preview tool.
  5. Review diagnostics, changes, affected files, and input revisions.
  6. Apply the returned plan_id with a new stable idempotency_key.
  7. Build declared outputs and read returned immutable artifact resources.

Plans expire, are authorization-bound, and fail after their input revision changes. Cancellation before commit leaves no persistent changes. Once atomic commit begins, Sora completes it or rolls it back.

Tool reference

Every tool rejects unknown input fields and returns structured output. Business failures set MCP isError: true and preserve diagnostics in the same envelope. All tools declare read-only, destructive, idempotent, and open-world hints.

ToolMain inputEffect
sora_server_info{}Protocol and workspace summary; read-only.
sora_project_list{}Discover manifests inside allowed Roots.
sora_project_openroot_id?, relative_manifest?Open a project; missing selection may use Elicitation.
sora_project_inspectproject_idSchema identity, groups, views, sources, targets, and formats.
sora_project_initroot_id, relative_directory, schema_idPreview a new scaffold.
sora_project_init_applyplan_id, idempotency_keyAtomically create the previewed scaffold.
sora_schema_validateproject_idNormalize and validate schema.
sora_schema_searchproject_id plus kind/name/field/type/reference filtersSearch normalized entities.
sora_schema_previewproject_id, expected schema/manifest revisions, ordered operationsProduce an immutable schema plan and text diffs.
sora_schema_applyproject_id, plan_id, idempotency_keyAtomically apply a schema plan.
sora_data_validateproject_id, optional view, tablesValidate selected or all project data.
sora_table_queryproject_id, table, optional filters/key/index/select/order/cursor/limit/localeQuery validated typed rows; limit is 1–500.
sora_data_diffproject_id, other_data_rootCompare a project-relative baseline root.
sora_data_previewproject_id, expected schema/data revisions, ordered operationsPreview typed row and localization changes.
sora_data_applyproject_id, plan_id, idempotency_keyAtomically apply a data plan.
sora_excel_sync_previewproject_id, expected schema/data revisionsPreview workbook synchronization.
sora_excel_sync_applyproject_id, plan_id, idempotency_keyAtomically synchronize XLSX workbooks.
sora_buildproject_id, expected project revision, optional view/output groups/targets/formats/cleanRun the declared build graph.
sora_codegenproject_id, revision, target, optional view/cleanRun one declared generator target.
sora_exportproject_id, revision, format, optional view/cleanRun one declared exporter.
sora_schema_lockproject_id, revision, optional view/cleanBuild the declared schema lock.
sora_excel_templateproject_id, revision, optional view/cleanBuild declared Excel templates.

Long-running operations and Tasks

Sora implements the io.modelcontextprotocol/tasks extension. Clients declare it in each request’s _meta.io.modelcontextprotocol/clientCapabilities.extensions. For build, codegen, export, schema-lock, Excel-template, and both Excel synchronization tools, Sora returns a resultType: "task" handle when the client supports the extension. Other tools return their completed result directly. Clients without the extension execute the same operation synchronously, with normal request progress and cancellation. There is no legacy task wire adapter, per-tool execution.taskSupport, or client task metadata.

Poll tasks/get with taskId, respecting pollIntervalMs (250 ms). The response is a DetailedTask: terminal completed includes the original tool result, including business failures with isError: true; failed includes a JSON-RPC error. tasks/cancel acknowledges cancellation intent, not completion. A worker that passed its commit point may still complete successfully. The task resource exposes the same new task shape and remains authorization- and project-bound.

Execution ttlMs starts at task creation (five minutes). The deadline requests real cooperative cancellation; it does not abort an atomic commit or fabricate a terminal status. Completed records are retained for up to another minute, with an updated ttlMs. Each store permits eight active tasks per authorization owner and at most 64 records across all owners; older terminal records may be evicted to admit work. Tasks are in-memory and do not survive a server restart.

Sora tasks do not request client input: tasks/update returns an explicit error. Observe tasks by polling; task push notifications and task progress notifications are not provided. tasks/list, tasks/result, and client-side task TTL requests are not supported. Roots, project selection, and script trust complete through input_required / inputResponses retries before task acceptance, never from a worker or through task input requests.

Resource change notifications use subscriptions/listen; clients select resource URIs and cancel that listen request to unsubscribe.

Resource reference

Fixed resources:

  • sora://server/info
  • sora://workspace/projects
  • sora://project/{project_id}/summary
  • sora://project/{project_id}/manifest
  • sora://project/{project_id}/capabilities
  • sora://project/{project_id}/schema
  • sora://project/{project_id}/diagnostics
  • sora://project/{project_id}/revision

Templates:

  • sora://project/{project_id}/schema/{kind}/{name}
  • sora://project/{project_id}/table/{table}/schema
  • sora://project/{project_id}/table/{table}/rows?cursor=&limit=&select=
  • sora://project/{project_id}/artifact/{artifact_id}
  • sora://project/{project_id}/task/{task_id}
  • sora://docs/overview and sora://docs/safety

Row cursors are revision-bound. Artifacts and tasks have TTLs and are readable only by their authorization context and project.

Prompt and Completion reference

The seven guided prompts are:

  • sora_create_table
  • sora_add_field_with_migration
  • sora_rename_entity_safely
  • sora_fix_validation_errors
  • sora_add_codegen_target
  • sora_prepare_config_release
  • sora_review_schema

Every prompt requires project_id, embeds a bounded project summary, links the schema and diagnostics resources, and preserves preview/apply safety. Completion supports project, entity kind/name, table, field, enum, group, view, source, codegen target, runtime format, export format, locale, artifact, and mode arguments.

Security and trust

  • Roots are explicit capabilities. Paths are root-relative, canonicalized, and checked against traversal and external symlinks.
  • Project cells, comments, schema text, prompt content, and documentation are untrusted data and never commands.
  • Lua trust is recorded for the exact script paths and SHA-256 digests in one authorization context. Another subject must make an independent decision.
  • Formatters use a built-in executable allowlist and argument arrays, never a shell command string.
  • clean deletes only manifest-declared generated outputs inside validated output roots.
  • Mutation plans bind authorization, project, revision, TTL, and idempotency.
  • Audit events contain tool, project, authorization fingerprint, revisions, outcome, duration, and bounded change counts; bearer tokens and bodies are never logged.

MCP Inspector walkthrough

MCP Inspector requires a current Node.js release.

Build Sora, then inspect stdio:

cargo build -p sora-cli
npx @modelcontextprotocol/inspector \
  target/debug/sora mcp \
  --project "$(pwd)/examples/showcase/project.toml"

In the UI:

  1. Confirm protocol 2026-07-28 and inspect the Tools, Resources, and Prompts tabs.
  2. Call sora_project_inspect for the opened project.
  3. Read its revision, schema, and diagnostics resources.
  4. Call sora_table_query for table Item with limit: 2.
  5. Run a preview tool and verify that no files change.
  6. Apply only a disposable plan, then repeat the same idempotency key to verify the stored result.
  7. Start a build and observe progress, Tasks when available, and artifact links.

CLI smoke checks are also useful:

npx @modelcontextprotocol/inspector --cli \
  target/debug/sora mcp --project "$(pwd)/examples/showcase/project.toml" \
  --method tools/list

npx @modelcontextprotocol/inspector --cli \
  target/debug/sora mcp --project "$(pwd)/examples/showcase/project.toml" \
  --method resources/list

For local Streamable HTTP, start Sora with Inspector’s Origin:

sora mcp --transport http \
  --allowed-origin http://localhost:6274

npx @modelcontextprotocol/inspector

Select Streamable HTTP and enter http://127.0.0.1:8000/mcp.

Showcase verification

The checked-in showcase is the recommended read-only end-to-end project:

cargo test -p sora-mcp --test read_only_workflow
cargo test -p sora-mcp --test build_workflow

The first test opens the showcase, inspects it, queries validated Excel-backed data, and verifies that revisions do not change. The second runs a transactional build, observes progress, and reads the immutable artifact through MCP.

Transactional writes back up existing targets under .sora/backups/<transaction-id>/ before replacement or deletion. The directory ignores itself from Git and retains the 20 most recent completed backup sets; backups belonging to active or recoverable transactions are never pruned.

CLI Reference

Use sora --help for the installed binary’s exact help text, and sora <command> --help for command-specific options. This page summarizes the common workflow commands, aliases, and short flags.

Global Options

Global options can be placed before or after the subcommand.

OptionDescription
-j, --jobs <N>Maximum worker threads. Must be greater than zero.
--serialDisable parallel execution.
--parser-script <PATH>Load a custom Lua cell parser script. Can be repeated. Project-level parser scripts can also be configured in [parsers].scripts in project.scon.
--type-mapping-script <PATH>Load a custom Lua type mapping script. Can be repeated. Project-level scripts can also be configured in [type_mappings].scripts in project.scon.
-h, --helpPrint help.
-V, --versionPrint the CLI version.

Lua source loaders are intentionally project-level only. Declare them in [source_loaders].scripts; every project-reading command that loads table data discovers them from the manifest. There is no command-line flag that turns an arbitrary script into a source loader.

Command Aliases

CommandAliases
buildb
checkc
initi
geng
exporte
diffd
excel-templatetemplate, et
excel-syncsync, es
schema-locklock, sl
studiost

Common Short Flags

ShortLongUsed by
-p--projectProject-reading commands.
-o--outinit, gen, export, diff, excel-template, schema-lock.
-v--viewbuild, gen, export, diff, excel-template, excel-sync, schema-lock.
-t--targetbuild, gen.
-f--formatexport.
-d--data-rootbuild, export, excel-sync.
-l--lock, --left-rootcheck, diff.
-r--right-rootdiff.
-c--cleanbuild.
-w--writeexcel-sync.

Commands

init

Create a new project scaffold.

sora init --out my-config
sora i -o my-config --schema-format yaml
OptionDescription
-o, --out <DIR>Output directory for the scaffold.
`–schema-format <scontoml
--forceAllow writing into an existing scaffold path.

check

Validate a project schema, optionally against a schema lock.

sora check --project project.scon
sora c -p project.scon -l generated/schema.lock
OptionDescription
-p, --project <PATH>Project manifest path.
-l, --lock <PATH>Existing schema lock to verify against.

build

Run outputs declared in [build] in project.scon, such as schema locks, Excel templates, codegen, and exports.

sora build --project project.scon
sora b -p project.scon -t rust -c
OptionDescription
-p, --project <PATH>Project manifest path.
`–default-source-format <csvjson
-d, --data-root <DIR>Data input root. Overrides [build].data_root.
-v, --view <NAME>Build only the schema projection declared by a view.
-t, --target <NAME>Codegen target to run. Can be repeated.
-c, --cleanDelete selected generated outputs before rebuilding.

gen

Generate code for one target directly, without using [build.codegen].

sora gen --target rust --project project.scon --out generated/rust
sora g -t typescript -p project.scon -o generated/typescript
OptionDescription
-t, --target <NAME>Codegen target, such as rust, typescript, or python.
-p, --project <PATH>Project manifest path.
-o, --out <DIR>Output directory.
`–format-code <neverauto
-v, --view <NAME>Generate only the schema projection declared by a view.

export

Load table data and export runtime data.

sora export --project project.scon --data-root data --format json --out generated/config.json
sora e -p project.scon -d data -f binary -o generated/config.sora
OptionDescription
-f, --format <NAME>Export format, such as binary, json, debug-json, cbor, sora-protobuf, or typed-protobuf.
`–default-source-format <csvjson
-p, --project <PATH>Project manifest path.
-d, --data-root <DIR>Data input root.
-o, --out <PATH>Output file or directory, depending on export format.
-v, --view <NAME>Export only the schema projection declared by a view.
`–compression <nonezstd>`
--compression-level <N>Compression level for compressed exports.

diff

Compare two data roots using the same project schema.

sora diff --project project.scon --left-root old-data --right-root data --out generated/diff.json
sora d -p project.scon -l old-data -r data -o generated/diff.json
OptionDescription
`–default-source-format <csvjson
-p, --project <PATH>Project manifest path.
-l, --left-root <DIR>Baseline data root.
-r, --right-root <DIR>Changed data root.
-o, --out <PATH>Diff output path.
-v, --view <NAME>Diff only the schema projection declared by a view.

excel-template

Generate empty Excel workbooks from the schema. Use this for new workbooks, not for existing data files.

sora excel-template --project project.scon --out generated/excel
sora et -p project.scon -o generated/excel
OptionDescription
-p, --project <PATH>Project manifest path.
-o, --out <DIR>Output directory for generated workbooks.
-v, --view <NAME>Generate templates only for the schema projection declared by a view.

excel-sync

Preview or apply schema header updates to existing Excel data workbooks while preserving data rows. Removed schema fields stay as ignored legacy columns.

sora excel-sync --project project.scon --data-root data
sora es -p project.scon -d data -w
OptionDescription
-p, --project <PATH>Project manifest path.
-d, --data-root <DIR>Data workbook root.
-v, --view <NAME>Sync only the schema projection declared by a view.
-w, --writeWrite workbook changes. Without this flag, the command previews changes only.

schema-lock

Write a schema lock for the current normalized schema.

sora schema-lock --project project.scon --out generated/schema.lock
sora sl -p project.scon -o generated/schema.lock
OptionDescription
-p, --project <PATH>Project manifest path.
-o, --out <PATH>Schema lock output path.
-v, --view <NAME>Lock only the schema projection declared by a view.

studio

Start the embedded Sora Studio schema editor.

sora studio --project project.scon
sora st -p project.scon --port 5180
OptionDescription
-p, --project <PATH>Project manifest path.
--host <IP>Bind address. Defaults to 127.0.0.1.
--port <PORT>Port. Defaults to 5174.

Data Export

Sora separates data export from language code generation.

The exporter receives validated data and writes a runtime bundle. Generated code then reads one of those bundle formats. This lets the same schema and data feed several languages or runtime storage choices.

The short version:

source data -> export format -> generated code runtime_format

For example, if generated Rust code uses runtime_format = "sora", the build must also write a binary export. Code generation decides how to read; export writes the file that will be read.

Built-in Exports

FormatPurpose
binaryNative sectioned Sora binary bundle.
json-debugHuman-readable debug output for inspection.
jsonRuntime JSON bundle.
cborRuntime CBOR bundle.
sora-protobufRuntime Protobuf bundle using Sora’s value model.
protoTyped Protobuf bundle using a generated game-specific schema.
i18n-binaryBinary locale pack for one locale.
i18n-jsonJSON locale pack for one locale.

The binary export is selected by runtime_format = "sora" in codegen options.

Command Example

sora export \
  --format binary \
  --default-source-format xlsx \
  --project project.scon \
  --data-root data \
  --out generated/config.sora

Build Manifest Example

Build manifests can declare multiple exports:

[[build.exports]]
format = "binary"
out = "generated/config.sora"

[[build.exports]]
format = "json-debug"
out = "generated/debug-json"

[[build.exports]]
format = "i18n-binary"
out = "generated/i18n/zh_cn.sora-i18n"
locale = "zh_cn"

When sora build runs, it checks that configured codegen targets have a matching export for their selected runtime format.

Localization packs are separate runtime assets and are mounted by the generated i18n runtime. See Localization.

Export Formats

Export formats are runtime bundle formats. They are independent from source formats such as Excel, CSV, TOML, JSON, or YAML.

ExportCodegen Runtime FormatOutput ShapeUse When
binarysoraNative sectioned binary bundle.You want a compact self-contained Sora runtime.
jsonjsonRuntime JSON bundle.You want easy inspection or simple platform integration.
cborcborRuntime CBOR bundle.You want a compact general-purpose binary value format.
sora-protobufsora-protobufSora value model encoded with Protobuf.You want Protobuf-based transport without per-game .proto models.
protononeTyped Protobuf bundle using the generated game-specific schema.You want a business .proto contract for external tooling.
json-debugnonePer-table debug JSON.You want reviewable output for inspection and tests.
i18n-binarynoneNative binary locale pack for one locale.You want production localization packs mounted separately from config.
i18n-jsonnoneDebug JSON locale pack for one locale.You want reviewable text for translation handoff or tests.

Example build outputs:

[[build.exports]]
format = "binary"
out = "generated/config.sora"

[[build.exports]]
format = "json"
out = "generated/config.json"

[[build.exports]]
format = "json-debug"
out = "generated/debug-json"

[[build.exports]]
format = "i18n-binary"
out = "generated/i18n/zh_cn.sora-i18n"
locale = "zh_cn"

Generated runtimes only load runtime formats they support. json-debug is for humans and tools, not generated runtime loading.

Localization exports require [localization] and a locale in the build manifest. See Localization.

Code Generation

Code generation turns the normalized schema IR into target-language row types, table containers, and config loaders.

It is driven by a registry of language generators.

Each generator declares:

  • a target id and aliases;
  • display metadata;
  • supported runtime formats;
  • optional formatter integration;
  • a CodeGenerator implementation.

This lets built-in languages and downstream generators use the same pipeline shape.

schema files -> schema model -> normalized IR -> generator registry -> target generator -> files

Generate a target directly:

sora gen --target typescript --project project.scon --out generated/typescript

Or declare it in the build manifest:

[[build.codegen]]
target = "typescript"
out = "typescript/generated"
format = "auto"

format can be never, auto, or required. auto runs a known formatter when it is available. required fails if the formatter is missing or returns an error.

Runtime Format

Each target can choose a runtime format:

[codegen.typescript]
runtime_format = "json"

The selected runtime format controls the loader code emitted for that target. It does not change the schema or the source data.

Generated Shape

Generated code generally contains:

  • enums for schema enums;
  • record types for structs, union variants, and table rows;
  • table containers for map, list, and singleton tables;
  • lookup helpers for keys and indexes where supported;
  • a top-level config loader for the selected runtime format.

Generated identifiers follow target-language conventions while runtime data lookup keeps using the original schema names. See Identifier Naming.

Schema optional<T> is mapped to the target language’s strongest available nullability representation. See Nullability.

Identifier Naming

Schema names are the source of truth. Generated code adapts those names to each target language’s naming style, while runtime data lookup keeps using the original schema names.

For example, a schema field named max_stack may become maxStack in TypeScript, MaxStack in C#, and max_stack in Rust. The generated decoder still reads the field named max_stack from the runtime bundle.

Naming Pipeline

Sora derives common name forms from each schema name before language generation:

FormExample from max_stackCommon use
Rawmax_stackRuntime table names, field names, enum text values, union tags.
PascalMaxStackTypes, classes, enum variants, exported symbols.
CamelmaxStackFields, properties, parameters, methods in camel-case languages.
Snakemax_stackFiles, modules, fields, functions in snake-case languages.

Language generators choose from these forms and may apply additional language-specific sanitization for invalid identifiers or reserved words.

Qualified schema names are projected as namespaces where the target has a stable module boundary. For example, game.items.Item becomes game/items/item.rs plus Rust modules, Game.Items.Item under the configured C# root namespace, and game/items/item.ts with ESM namespace barrels. Godot uses namespace directories and a globally unique class_name such as GameItemsItem because GDScript classes share a global registry.

Targets whose generated runtime is intentionally a single package or translation unit encode the complete qualified name into a collision-free symbol instead: game.items.Item becomes GameItemsItem or game_items_item according to that language. C, C++, Go, Java, Kotlin, Scala, Dart, Python, Lua, Erlang, and protobuf currently use this representation. This keeps two declarations such as items.Entry and quests.Entry distinct without relying on source-file order.

Language Conventions

The built-in generators follow the target language’s normal public API style:

TargetTypesFields and accessorsFiles/modules
RustPascalCasesnake_casesnake_case.rs
Cprefixed snake_casesnake_casesnake_case.h, snake_case.c
C++PascalCasesnake_casesnake_case.hpp
C#PascalCasePascalCase propertiesPascalCase.cs
GoPascalCase exported namesPascalCase exported fieldssnake_case.go
JavaPascalCaselowerCamelCasePascalCase.java
KotlinPascalCaselowerCamelCasetarget layout
ScalaPascalCaselowerCamelCasePascalCase.scala
TypeScriptPascalCaselowerCamelCasesnake_case.ts
JavaScriptPascalCaselowerCamelCasesnake_case.js, snake_case.d.ts
PythonPascalCasesnake_casesnake_case.py
DartPascalCaselowerCamelCasesnake_case.dart
LuaPascalCase table-like typeslowerCamelCasesnake_case.lua
Erlangsnake_case modulessnake_case map keys/functionssnake_case.erl
GodotPascalCase classessnake_casesnake_case.gd

This table describes generated code identifiers, not runtime data names.

Runtime Names Stay Raw

The following values keep the original schema spelling:

  • canonical qualified table names in bundles and table metadata;
  • field names read from runtime rows;
  • enum string values;
  • union variant tag values;
  • schema lock and fingerprint input.

Changing a schema name changes the data contract. Changing only a target language’s generated identifier style should not.

Custom Type Mappings

Custom type mappings do not rename generated schema identifiers. They only control target-language type expressions, imports/includes, and optional conversion hooks.

Mapping function names are native code written by the user for that target language, so they should follow that language’s own naming convention. The mapping key remains the named schema type, such as Vec3.

Nullability

Schema nullability is expressed with optional<T>. Code generators map that schema type to the strongest nullability representation available in each target language.

Runtime bundles encode optional values with explicit presence. Generated code should preserve that distinction in its public API instead of relying on undocumented null conventions.

Built-In Representations

Targetoptional<T> representation
RustOption<T>
C#T? with nullable reference types enabled
KotlinT?
DartT?
ScalaOption[T]
TypeScript`T
JavaScript d.ts`T
Python`T
C++std::optional<T> for C++17 and newer; SoraOptional<T> for older standards
Cgenerated optional wrapper type with presence state
Go*T
Erlang`T
LuaT? EmmyLua annotation
GodotVariant with null
Javanullable value type plus annotation

Dynamic targets such as JavaScript, Lua, and Godot can only document nullability for tooling. Statically typed targets expose it in the generated type whenever the language supports that.

Java Annotations

Java has no standard nullable type syntax. Sora emits nullable Java fields, constructor parameters, and nullable lookup results with an annotation.

By default, Java generation uses a self-contained package-local SoraNullable annotation:

@SoraNullable
public final String nickname;

Projects that use a specific annotation package can configure it:

[codegen.java]
nullable_annotation = "org.jetbrains.annotations.Nullable"

Set nullable_annotation = "" to emit nullable Java values without annotations.

Custom Type Mappings

Type mapping scripts can provide nullable_type_name when the target language needs a different type expression for optional<YourType>:

{
  target = "java",
  schema_type = "UserId",
  type_name = "int",
  nullable_type_name = "Integer",
}

This only changes the generated type expression. The backend still controls how optional presence is decoded.

Runtime Formats

Select a runtime format per codegen target:

[codegen.rust]
runtime_format = "sora"

Runtime formats are the formats generated code can load. They correspond to export formats:

Codegen runtime_formatRequired Export
sorabinary
jsonjson
cborcbor
sora-protobufsora-protobuf

This setting does not change Excel, CSV, TOML, JSON, YAML, or schema files. It only changes the loader generated for the target language. The selected runtime format must have a matching export in the project build.

Support Matrix

Targetsorajsoncborsora-protobuf
Rustself-containedmanaged dependencymanaged dependencymanaged dependency
Kotlinself-containedmanaged dependencymanaged dependencymanaged dependency
C#self-containedmanaged dependencymanaged dependencymanaged dependency
Javaself-containedmanaged dependencymanaged dependencymanaged dependency
Scalaself-containedmanaged dependencymanaged dependencymanaged dependency
Goself-containedmanaged dependencymanaged dependencymanaged dependency
TypeScriptself-containedmanaged dependencymanaged dependencymanaged dependency
JavaScriptself-containedmanaged dependencymanaged dependencymanaged dependency
Pythonself-containedmanaged dependencymanaged dependencymanaged dependency
Dartnot supportedstandard libraryuser adapteruser adapter
Godotnot supportedstandard librarynot supportednot supported
Cself-containednot supportednot supportednot supported
C++self-containednot supportednot supportednot supported
Erlangself-containeduser adapteruser adapteruser adapter
Luaself-containeduser adapteruser adapteruser adapter

Dependency meanings:

KindMeaning
self-containedGenerated runtime includes the decoder.
standard libraryGenerated runtime uses the language standard library.
managed dependencyGenerated runtime expects normal package dependencies for that ecosystem.
user adapterGenerated runtime exposes an adapter hook and the application supplies the concrete decoder.

Choosing a Format

Use sora when you want the native Sora binary bundle and the target supports it.

Use json when inspectability, tooling, or platform simplicity matters more than compactness.

Use cbor when you want a compact general-purpose binary value format and your runtime already has a CBOR dependency.

Use sora-protobuf when your environment prefers Protobuf transport but you still want Sora’s schema-driven value model.

The CI runtime matrix generates every supported combination in this table and syntax-checks languages where the check is lightweight.

Godot JSON and Generated Types

Godot codegen targets Godot 4.3 by default. Set the target version when the generated project uses a newer Godot release:

[codegen.godot]
runtime_format = "json"
godot_version = "4.4"

Generated GDScript uses concrete table key and index parameter types, typed row arrays, and one class per discriminated-union variant. Godot 4.4 and newer additionally use typed dictionaries; Godot 4.3 keeps dictionaries untyped because that syntax is unavailable there. Nested typed collections fall back to Array or Dictionary where GDScript does not allow a typed collection as another typed collection’s element type.

The generated JSON loader preserves i64, duration, and datetime integer values outside JSON’s interoperable safe-integer range. It quotes those number tokens internally before calling Godot’s JSON parser, then decodes their decimal strings back to exact 64-bit integers. The exported JSON format itself does not change.

Optional primitive values remain Variant because GDScript has no nullable primitive type. Optional generated records, unions, and text keys retain their concrete object type and use null as the empty value.

Runtime Adapters

Some languages do not have a built-in dependency story for every runtime format. Those targets use adapter hooks instead of embedding a third-party decoder.

The generated runtime owns the Sora value model and table loading logic. The application supplies a small function that turns bytes into the decoded value tree expected by the runtime.

This keeps generated code independent from dependency choices. A game can use the CBOR, Protobuf, or compression library it already trusts.

Lua

local config = SoraConfig.from_cbor(bytes, {
  decode_cbor = function(payload)
    return my_cbor.decode(payload)
  end,
})

Erlang

Options = #{
    decode_cbor => fun my_cbor:decode/1
},
Config = sora_config:from_cbor(Bytes, Options).

Dart

final config = SoraConfig.fromCbor(
  bytes,
  decodeCbor: (payload) => myCborDecode(payload),
);

Adapters keep generated code independent from dependency choices while still allowing the same exported data formats to be used.

What the Adapter Must Return

The adapter should return the language-specific Sora value tree expected by the generated runtime. It is not responsible for constructing typed rows; generated code handles that after decoding.

If a target has a self-contained decoder for a format, no adapter is needed.

Versioning and Compatibility

Sora is still early. The project does not provide Rust-style editions or compatibility modes for old schema semantics. A project that needs stable output should pin the sora CLI version it uses, and treat a CLI upgrade as an explicit migration step.

What To Pin

Pin the CLI binary or crate version in the project tooling:

  • download a specific GitHub Release asset and keep using that version in CI;
  • install a specific crates.io version with cargo install sora-cli --version X.Y.Z;
  • record the expected sora --version in project setup docs or build scripts.

Generated code, generated Excel templates, schema locks, and exported runtime bundles should be produced by the same pinned CLI version for a given project build.

Runtime Bundle Versions

Exported runtime bundles carry a format version. The Sora binary bundle also has a file header version, and generated runtimes reject bundles with unsupported versions.

Sora only bumps these runtime/export format versions when the generated runtime can no longer safely read data written by an older layout. Examples include:

  • changing the .sora binary section layout;
  • changing the manifest fields required by generated runtimes;
  • changing JSON, CBOR, or Protobuf bundle structure in a way that old generated code cannot read;
  • changing value encoding rules in exported runtime bundles.

During the early development stage, ordinary implementation changes do not automatically bump format_version. Version bumps are manual and reserved for actual runtime/export incompatibility.

Schema and Codegen Semantics

Schema syntax, parser behavior, validation rules, Studio rendering, and generated language APIs may still change while the project is young. Sora does not keep old behavior behind an edition flag or any other compatibility mode.

If a newer CLI changes schema or codegen semantics, users should:

  1. upgrade the CLI intentionally;
  2. regenerate schema locks, templates, exports, and code;
  3. review diffs;
  4. update schema/data/project files as needed.

Schema fingerprints and schema locks help detect mismatches between generated code, schema, and data, but they are not migration tools. They prevent silent incompatibility; they do not preserve old semantics.

Extending Sora

Sora is designed to be used as a library by projects that need their own language or data format support.

The extension boundary is intentionally split:

input adapter -> schema model -> normalized IR -> data validation
                                      |-> exporter
                                      |-> code generator

Add a Code Generator

Implement the generator trait:

#![allow(unused)]
fn main() {
pub trait CodeGenerator: Send + Sync {
    fn generate(&self, context: CodegenContext<'_>, out_dir: &Path) -> Result<()>;
}
}

Register it with an id, aliases, runtime capabilities, and optional formatter configuration.

See Generators for a longer walkthrough.

Add a Project Source Format

For project-owned, read-only table formats, register a Lua Source Loader. The host provides source-root-scoped file and JSON capabilities; rows then enter the normal Sora validation pipeline.

Keep the IR Neutral

Language-specific settings belong in target options and generator code. The normalized IR should describe schema semantics only: packages, tables, fields, types, keys, indexes, unions, and validation metadata.

Project-specific language type mappings should use codegen type mapping providers, not schema fields. This keeps data semantics separate from target-language representation choices such as mapping struct<Vec3> to UnityEngine.Vector3.

Add an Exporter

Exporters are separate from generators. Add a data exporter when you need a new runtime bundle format. Add a code generator when you need a new language target.

See Exporters for the expected boundary.

Lua Source Loaders

Lua source loaders let a project translate a custom, possibly multi-file format into ordinary Sora rows while keeping the standard build, export, query, validation, Studio, and MCP workflows. They are format-neutral project extensions, not dynamic native plugins.

Configuration

Declare one or more scripts in the project manifest. Script paths must be project-relative and cannot contain ..:

[source_loaders]
scripts = ["tools/source_loaders.lua"]

Then select the registered format on a table. A directory has no useful extension, so it normally uses an explicit format:

[tables.source]
format = "custom_format"
file = "catalog/items"

A loader may declare extensions. Sora can infer the format when source.format is omitted and the source file has one of those extensions.

Script API

A script returns a source_loaders table. Sora calls load once per table source, not once per cell:

return {
  source_loaders = {
    custom_format = {
      extensions = { "custom" },
      load = function(source, ctx)
        local rows = {}
        for _, entry in ipairs(ctx.list(".")) do
          if entry.kind == "file" then
            local document = ctx.json_decode(ctx.read_text(entry.path))
            table.insert(rows, {
              id = document.id,
              name = document.name,
              tags = document.tags,
            })
          end
        end
        return { rows = rows }
      end,
    },
  },
}

source contains the relative configured path, the table name, and the selected format. The loader must return { rows = { ... } }, where every row is a string-keyed object.

Returned boolean, integer, finite float, UTF-8 string, array, and string-keyed object values recursively become sora_data::Value. Use ctx.null for an explicit null value. ctx.json_decode also represents every JSON null as this sentinel, so null object fields and null array elements are preserved. Lua nil still means an absent table entry and therefore cannot represent a stored null. Non-empty sequential Lua tables are arrays; empty {} is an object. Use ctx.array({}) when an empty array is required. Objects are materialized in key order. After loading, Sora still applies its normal schema, required, enum, ref, range, table-mode, view, localization-catalog, and export validation.

The host context provides:

APIResult
ctx.read_text(relative_path)Read one UTF-8 file.
ctx.read_bytes(relative_path)Read one file as a Lua byte string.
ctx.list(relative_directory)Return direct entries sorted by normalized relative path. Each entry has path, name, and kind (file or directory).
ctx.json_decode(text)Decode JSON into Lua values, preserving empty arrays.
ctx.nullStable sentinel for a stored null value; do not mutate it.
ctx.array(table)Mark a Lua table, including an empty one, as an array.
ctx.error(diagnostic)Stop with a structured diagnostic.

A diagnostic has message, optional relative path, optional line, column, and field:

ctx.error({
  path = "parts/header.custom",
  line = 4,
  column = 9,
  field = "id",
  message = "invalid record id",
})

Use dot calls (ctx.read_text("part.json")), not colon calls.

Security, trust, and determinism

Lua does not receive io, os, package, debug, require, dofile, loadfile, shell, network, system-time, printing, or random-number capabilities. It cannot load native libraries or start processes. The standard table, string, deterministic math, and UTF-8 libraries remain available.

All filesystem operations go through the Rust host. Paths are relative to the current table source root; absolute paths and .. are rejected. Canonicalization rejects symlinks that escape that root. A file source permits only .; a directory source can enumerate and read descendants. Enumeration order is stable.

The runtime enforces per-load limits: 100,000 rows, value depth 64, 4,096 file reads, 64 MiB read bytes, 100,000 listed entries, 128 MiB Lua memory, and 10,000,000 Lua instructions. ExecutionContext cancellation is checked before and during Lua execution. These limits are currently fixed rather than configurable.

Sora records the script path and SHA-256 plus every file actually read by the host. Source-loader scripts and data-root contents participate in the project/data revision, so script or input changes invalidate revision-bound work. MCP project discovery treats source-loader scripts like parser and type-mapping scripts: trust is required for the exact inspected path and digest before the project is opened.

Built-in formats cannot be overridden. Repeating a format or extension across loaders fails project loading, including extensions owned by built-in formats. This keeps extension inference unambiguous; omit extensions and use explicit source.format when a project format must read a conventionally named file.

Limitations

Lua source loaders are read-only in the first version. Data Mutation and Studio write-back reject a custom source format as not mutable; Sora never guesses a built-in writer. Localization sources are not supported by this table-loader API. There is no runtime installation, dynamic library ABI, external process execution, or network plugin mechanism.

Generators

A generator turns the normalized IR into files for one language target.

Registration

Generators are registered with:

  • a canonical target id;
  • aliases;
  • display metadata;
  • supported runtime formats;
  • optional formatter integration;
  • a CodeGenerator implementation.

This lets built-in generators and downstream generators use the same pipeline.

Implementation Shape

#![allow(unused)]
fn main() {
pub trait CodeGenerator: Send + Sync {
    fn generate(&self, context: CodegenContext<'_>, out_dir: &Path) -> Result<()>;
}
}

The generator receives:

  • the normalized IR;
  • parsed target options;
  • the registered type mapping providers;
  • the output directory;
  • runtime format selection.

It should not mutate the IR or rely on language-specific fields being present in the IR.

Type Mappings

Language generators can consult context.type_mappings before falling back to their built-in type mapping. A provider maps a target plus a named schema type, such as struct<Vec3>, to a generated type name, optional nullable type name, and optional decode wrappers. Container and optional types should recurse through the same mapper so list<struct<Vec3>> and optional<struct<Vec3>> automatically use the mapped target type.

The schema remains language-neutral. Project-specific mappings belong in library registration code or CLI Lua type mapping scripts, not in field definitions.

Target Options

Language-specific options live under [codegen.<target>]:

[codegen.rust]
runtime_format = "sora"
map_type = "btree"
string_storage = "owned"

The generator owns the interpretation of these options.

Exporters

An exporter writes validated configuration data into a runtime bundle.

Exporters are separate from code generators because the same exported data can be consumed by many languages.

When to Add an Exporter

Add an exporter when you need:

  • a new runtime wire format;
  • a platform-specific asset package;
  • a different compression or section layout;
  • an inspection format for tooling.

Do not add an exporter just to support a new programming language. Add a code generator for that.

Expected Boundary

An exporter should consume:

  • the normalized schema IR;
  • validated config data;
  • exporter options;
  • an output target.

It should not depend on a specific language generator.

Design Notes

These notes explain the architectural choices behind Sora.

The short version is that schema files are the source of truth. Excel headers, runtime bundles, generated code, and extension points are projections of the normalized schema and validated data.

Schema as Source of Truth

Sora is schema-first. The TOML schema is the contract for configuration data; source files and generated outputs are projections of that contract.

schema modules
  -> normalized IR
  -> Excel headers
  -> validation
  -> runtime exports
  -> generated language code

This design avoids the common problem where a spreadsheet, a hand-written parser, and runtime code all define slightly different versions of the same data shape.

Consequences

  • Field names, types, keys, defaults, references, and validation rules live in schema.
  • Excel and CSV files provide values, not a second schema.
  • Runtime export formats do not change the data model.
  • Language options belong to codegen targets, not to the IR.
  • Downstream users can add generators or exporters without changing schema semantics.

The schema can still include editing hints such as comment, parser hints, ranges, and length limits. Those hints are part of the data contract because they affect validation or generated projections.

Excel Header Projection

Excel templates are generated from the normalized schema. The header is a projection, not an independent format definition.

Why Generate Headers

Manually maintained spreadsheet headers tend to drift from code:

  • a field is renamed in code but not in Excel;
  • a type changes but old rows still look valid;
  • a designer adds a column that no runtime reads;
  • validation rules are documented in comments instead of enforced.

Sora avoids this by generating the workbook structure from schema.

What the Header Contains

Generated rows include:

  • table metadata: table name, mode, key, groups, and schema hash;
  • stable field names;
  • type hints;
  • group hints;
  • validation and parser rules;
  • comments for editors.

Only row data should be treated as authored content. Header rows can be regenerated whenever the schema changes.

Practical Workflow

  1. Change the schema.
  2. Regenerate Excel templates.
  3. Move or paste existing data rows into the updated template.
  4. Run sora build or sora export to validate values and references.
  5. Run sora build to produce exports and generated code.

This keeps Excel useful for editing while keeping the schema authoritative.

IR Boundaries

The normalized IR describes schema semantics. It should not encode language-specific codegen choices.

Belongs in IR

  • packages and included schema modules;
  • enums, structs, unions, tables, fields, and indexes;
  • table modes and keys;
  • source metadata;
  • field types, defaults, parsers, ranges, lengths, and comments;
  • references and derived child-table field metadata;
  • scopes.

Does Not Belong in IR

  • Rust map implementation choices;
  • TypeScript enum representation choices;
  • Lua module names;
  • runtime decoder dependency choices;
  • formatter settings;
  • target-specific file layout.

Those settings belong in [codegen.<target>] or in generator registration metadata.

Extension Boundary

schema input -> normalized IR -> validation
                              |-> exporter registry
                              |-> codegen registry

A new language generator should consume the IR and its own target options. A new runtime data format should be added as an exporter. Neither should require changing the schema model unless the actual data semantics need to change.