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

Tools and the build

The toolset is one executable that contains ten tools, plus kiln, which drives the others. This page is how a project is found, how the tools find each other, and how a distribution is assembled safely.

The boundary

---
config:
  layout: elk
---
flowchart LR
    subgraph tools["kerosene-tools (one binary)"]
        gui["Toolset window<br/>project, editor, sound, build, archive, models"]
        subs["headless subcommands<br/>init, cleave, umbra, resonance, radiance,<br/>alchemy, forge, timbre, kiln, vault"]
    end
    runtime["kerosene runtime"]
    archive["content.vault"]
    tools --> runtime
    gui -. "drives" .-> subs
    runtime --> archive
    subs --> archive

    classDef tool fill:#FF6D00,color:#fff
    classDef out fill:#2962FF,color:#fff
    class gui,subs tool
    class runtime,archive out

crates/kerosene-vfs/src/toolchain.rs is the practical definition. A subcommand is always present because it is compiled into the executable and run by re-invoking that same executable with the subcommand as its first argument (toolchain::command). This preserves the old crash-isolation property: a compiler that fails does not take the editor down with it. The runtime is a sibling binary first and on PATH second, because a checkout and an install both put the two in one directory.

tools/kerosene-tools/src/lib.rs lists the subcommands in one SUBCOMMANDS array and dispatches them in run_subcommand. tools/kerosene-tools/src/entry.rs turns the binary into a function, main_with(Options), so a game can ship its own tools that know its classes:

const SCHEMA: &str = include_str!("../content/mygame.kdef");
fn main() -> anyhow::Result<()> {
    kerosene_tools::main_with(
        kerosene_tools::Options::new("mygame-tools", env!("CARGO_PKG_VERSION"))
            .schema(&[SCHEMA])
            .game("mygame"),
    )
}

Options::runtime() returns a Runtime::Package built from the working directory, so F9 in the editor and play build and launch the game rather than a binary called kerosene.

Two subcommands exist for game crates rather than for content:

  • new (src/new.rs) writes a game crate from the templates in src/new/template/: a package, its toolset binary, cargo aliases, a project file and a starter map built with kerosene-map. It depends on the Kerosene the toolset was built from: the checkout’s path, the release tag when installed from git, or the crates.io version.
  • play (src/play.rs) runs Kiln’s content stages incrementally, with the maps’ expensive passes skipped unless --full, then toolchain::resolve to build and run the game.

Kiln decides what is current by file times: an output no older than its source is skipped, a map also needs a .kbuild stamp saying it was built at least as thoroughly as asked (fast or full), and the archive is skipped when nothing it would pack is newer than it. --force rebuilds everything.

Finding the content

crates/kerosene-vfs/src/root.rs. Every tool needs the same answer to “where is the content”, and when each worked it out its own way they disagreed — and disagreeing about a directory looks, from the outside, like every one of them being broken in a different way.

---
config:
  layout: elk
---
flowchart TB
    start(["find()"]) --> explicit{"--content given?"}
    explicit -- yes --> verify{"is_content_root?"}
    explicit -- no --> project{"Project file found<br/>by climbing?"}
    project -- yes --> useP["use project.content"]
    project -- no --> cwd{"cwd is a content root?"}
    cwd -- yes --> useC["use cwd, why = ..."]
    cwd -- no --> climb["climb up to MAX_CLIMB=6 levels<br/>looking for kerosene.kdef<br/>or maps/ + materials/"]
    climb --> found
    verify --> found["Found { root, why, project }"]
    useP --> found
    useC --> found
    found --> scaffold["root::scaffold(dirs)<br/>create missing CONTENT_DIRS"]

    classDef dec fill:#AA00FF,color:#fff
    classDef proc fill:#FF6D00,color:#fff
    class explicit,project,cwd,climb,verify dec
    class found,scaffold,useP,useC proc

is_content_root accepts a kerosene.kdef file as the strong signal, or a directory with both maps and materials — a project that has not written its class definitions yet is still a project. Found.why records which rule fired, so a wrong guess explains itself; a wrong guess that explains itself costs a minute and a silent one costs an afternoon.

CONTENT_DIRS = ["maps", "materials", "art", "textures", "models", "sound", "scripts"] and scaffold creates any that are missing. A project can overrule the list with repeated "dir" keys (Project::dirs), and those names go through the same path::normalize the VFS uses, so .. or a drive letter is refused.

The project file

crates/kerosene-vfs/src/project.rs. .kproj is a project’s own account of where its content is. Everything before it infers the root by climbing the tree, which works for a fresh clone but is a guess that can be wrong in ways nobody can correct. A project file sits at the top, names the content directory relative to itself, and every tool that finds it stops guessing.

KeyMeaning
nametitle-bar name; defaults to the file stem
contentcontent tree, relative to the file; falls back to content/ or the file’s directory
startmapmap to load when nothing else says which (optional)
gamethe Cargo package that is the game; kiln --ship builds it and F9 launches it
binthat package’s binary, when not the package’s own name
dirrepeatable; overrides CONTENT_DIRS

Everything but the block itself is optional. A content-only project has no game and runs and ships the engine’s own runtime instead.

The VFS

crates/kerosene-vfs/src/lib.rs is a stack of search paths forming one virtual content tree. Each SearchPath is a directory or a mounted .vault plus an id (GAME, MOD, PLATFORM). Lookups are case- and separator-insensitive (Materials\Dev\Grid.kmat finds materials/dev/grid.kmat), which a loose tree has to agree on or a game works from a checkout and breaks the moment it is packed. Traversal cannot escape the root (path::normalize).

A read_normalized that hits a permissions error surfaces it rather than falling through to a stale copy in a lower layer. Writes land in the first directory layer; archives are read-only by design, because mutating a mounted archive under a running engine would invalidate every offset it has cached. write_atomic in the same file writes to a sibling temp and renames, so a crash mid-save cannot truncate a map someone spent a day on.

The .vault format

crates/kerosene-vfs/src/archive.rs. MAGIC = "KVLT", version 1, header 40 bytes, then a directory of entry_count records sorted by path, then a data blob:

[ header 40 bytes ]
[ directory: path_len:u16, path, crc32:u32, offset:u64, size:u64 ]
[ data blob ]

Paths are stored normalised, so lookup is a binary search with no per-query allocation. Every read is checked against the stored CRC32: a corrupt archive is a common failure (truncated download, bad copy) and is far better caught here than as a garbled texture three subsystems later. Loose files win over packed ones, which is the mod-development workflow — drop a file next to a shipped archive and it takes effect with no repack.

Kiln

tools/kiln/src/lib.rs, build(settings). Stages run in a fixed order; --only names one for iteration.

---
config:
  layout: elk
---
flowchart LR
    T["Textures<br/>alchemy (library)"] --> S["Sounds<br/>timbre (library)"]
    S --> M["Models<br/>forge subprocess"]
    M --> MP["Maps<br/>cleave → umbra → resonance → radiance"]
    MP --> P["Pack<br/>vault"]
    P --> SH["Ship (optional, --ship DIR)"]
    MP -. "leak?" .-> skip["report the leak,<br/>skip vis/acoustics/lighting"]
    skip -.-> P

    classDef stage fill:#FF6D00,color:#fff
    classDef warn fill:#D50000,color:#fff
    class T,S,M,MP,P,SH stage
    class skip warn
  • Textures and Sounds are library calls, not subprocesses. The reason is in tools/kiln/src/lib.rs and tools/alchemy/src/lib.rs: Chisel and Timbre’s own GUI make the same calls, and two callers of one step must not be able to disagree.
  • Models: art/props/crate.obj becomes models/props/crate.kmdl — the path under art is the path under models, so a model’s name is decided by where its source is.
  • Maps: each .kmap goes through all four compilers. A leak is reported rather than fatal to the whole build (finding out at the end of a forty-map build beats finding out on the first), and the remaining stages are skipped unless --ignore-leaks.
  • Pack: vault packs the content tree into the project’s archive (Settings::archive, inside the content tree where a shipped game keeps it).
  • Ship is deliberately not in Stage::ALL: building content is something done every few minutes, assembling a distribution is not.

Settings mirrors the CLI and Report collects counts for the toolset’s output panel. --fast passes through to Umbra and Radiance; --dry-run says what would run.

Ship

tools/kiln/src/ship.rs. A game is an executable, an archive, a project file telling the executable where the archive is, and the notices the licences require, arranged so double-clicking works:

dist/
  my_game            the game, or the engine runtime when a project has none
  my_game.kproj   content = "content", so the game finds its own archive
  content/
    my_game.vault
  LICENSE            GPLv3 full text
  LICENSE-EXCEPTION  the Kerosene Exception, full text
  README.txt         the notices the licence asks for

The doc in ship.rs names the two opposite failure modes. Shipping too little is the common one (forgotten licence texts, because nothing breaks), so they are written by code, compiled into kiln so shipping works from a directory that is not a checkout. Shipping too much would put Chisel and the compilers in a player’s hands — and that is not merely wasted space, because the tools are ordinary GPL binaries and distributing one obliges you to distribute its source too. So ship copies a named list of files, and a test asserts no tool ever appears in the result.

The toolset window

tools/kerosene-tools/src/toolset/ holds Toolset with seven tabs (Home, Assets, Editor, Models, Sound, Build, Archive) switched by the sidebar (sidebar.rs) and ctrl-1..ctrl-7, under a top bar (topbar.rs) with the palette’s search box, Play, and the running jobs. Editor, Models and Sound are the tools they used to be: ChiselApp, loupe, timbre::gui::Timbre. The others are pages in src/pages/: home.rs, assets.rs, build.rs, archive.rs, and start.rs for choosing a project.

Every page returns what was asked for as a toolset::Action, carried out after the frame, so a button, the sidebar and the command palette (kerosene_toolui::palette, fed by toolset/commands.rs) all mean one thing by “build”. Jobs are src/job.rs: the toolset re-run with a subcommand, its output piped back, cancellable as a whole process group. One output panel (kerosene_toolui::output::OutputPanel) receives every job’s log.

pages/assets.rs walks the content tree into an Index once, when the tree may have changed (a job finishing, Home or Assets coming up, Rescan), and Home, Assets, Archive and the palette all read that one index. Recent projects are src/recent.rs, plain lines in the user data directory.

cargo run -p kerosene-tools --example shot -- <tab> out.png draws the window off screen, the way Chisel’s ui_shot draws the editor.

tools/chisel is the editor proper. Its logic — document and undo, grid, viewport projection/picking, tools, compile pipeline — lives in the library and is tested without a window; the egui layer only draws it and turns clicks into calls (tools/chisel/src/lib.rs). That split is deliberate: an editor that can only be tested by clicking is an editor whose bugs are found by its users.

Next: Testing.