Skip to content

CLI

The dh command-line tool compiles, validates, and watches your pallets. For workspace configuration, see Workspace.

CommandDescription
dhLaunch interactive mode (default if no command given)
dh interactiveLaunch interactive mode (explicit)
dh buildCompile all pallets
dh build <pallet>...Compile the named pallets and their dependencies, as one build
dh watchWatch for changes and auto-compile
dh validateValidate pallets without writing output
dh cleanClear the .dh/ cache directory
dh initInitialize a new workspace
dh info <file>Show metadata from a compiled .pallet file
dh reactions [pallet]Show what each avatar’s dh.reaction components resolve to across its inherits chain and platform files (see avatar game events)
dh export <target> [avatar]Export an avatar to another platform; with no avatar, list what can be exported
dh map <verb> <map>List, add, move, set or remove a map’s objects in its .dh-map source, with the engine closed
dh import minecraftRun the Minecraft recipe: generate the com.mojang.minecraft pallet from your own Minecraft Java client jar; with no --out, list the jars found
dh import minecraft-world <world folder>Copy a Minecraft Java world save into a map pallet of its own under --out (the pallets root) and write its map, spawn point included; the overworld only unless --all-dimensions. With --list, list the installs and worlds found
dh import source-map <map>Turn a compiled Source map (by name or .bsp path) into a map pallet with the map’s own baked lighting; --list shows the maps a game offers
dh helpShow available commands

dh export hands an avatar from the compiled pallets to another platform. Every target shares one shape: the target’s name, the avatar’s barcode, --out, --rebuild and --verbose, plus options of the target’s own. With no avatar it lists the workspace’s avatars and each one’s export state.

TargetWhat it writesIts options
sourceA Garry’s Mod playermodel and its viewmodel hands (.gma), compiled by the game’s own studiomdl--game <garrysmod dir>, --proportions own|hl2, --fit hips|eyes, --spring-bones on|off, --hands on|off
Terminal window
dh export source io.mltn.avatars.mltn-mayu:mltn-mayu.dh-avatar
dh export source io.mltn.avatars.mltn-mayu:mltn-mayu.dh-avatar --fit eyes --out ./scratch
dh export source

An export whose content hash already has a .gma in the output folder is not compiled again; --rebuild compiles it anyway. See Source export for the settings and where the files go.

Every dh import command runs a recipe: DigitalHeaven ships the recipe and you supply the ingredients, your own copy of the game or world, so the pallet is generated on your machine.

dh import minecraft writes the com.mojang.minecraft source pallet (block definitions, materials and textures) from your own Minecraft Java client jar, so voxel content can be drawn with Minecraft’s own models. --out names the pallet folder. It finds the jars the official launcher, Prism, MultiMC and the Modrinth app keep and uses the newest release; --version <version> picks another found jar and --jar <path> names one directly. The jar is only read. See Generating the Minecraft pallet.

Terminal window
dh import minecraft
dh import minecraft --out Source/com.mojang/minecraft

dh import minecraft-world <world folder> --out <pallets root> copies a Java Edition world save into a map pallet of its own, com.mojang.minecraft.maps.<name>, made under the pallets root (the workspace’s Source folder) the way a Source map’s is, and writes the map of it: a .dh-vox naming the copy, and a .dh-map with a voxel volume, a spawn point read from level.dat and put on the ground found in the chunk, a thumbnail camera, and a sky and a sun. The world’s icon.png becomes the map’s thumbnail. The overworld is imported unless --all-dimensions is given, --name picks the pallet’s name, and a map already there is kept as it is when the import runs again unless --rewrite-map is given. A world Minecraft may have open is refused (its session.lock cannot be taken) unless --force, since copying it live tears its region files. --progress prints the machine-readable lines while the files copy. A schematic is not a world: it is placed through a .dh-vox. See Importing a world as a map.

--out here is the pallets root, never an existing pallet: pointing it at a folder that holds a pallet.dh is an error. dh import minecraft-world --list prints the installs on this machine and the worlds in them, and --list --json prints the same as the JSON that Studio reads (the contract). dh import minecraft --list [--json] prints the client jars found.

Terminal window
dh import minecraft-world --list
dh import minecraft-world --list --json --install "<game folder>"
dh import minecraft-world "<saves>/Tutorial" --out Source
dh import minecraft-world "<saves>/Tutorial" --out Source --rewrite-map --progress
dh import minecraft --list --json

dh import source-map turns a compiled Source map into a map pallet: the world’s geometry and displacements as a GLB with each face on its baked lightmap rectangle, hull colliders from the brushes, the imported lightmap, spawns, the sun, a 2D sky, reflection probes, and its static and entity props as object instances of models imported into the pallets that own them (--no-props skips them). The map is named (gm_construct, found through the game’s mount stack, Workshop addons included) or given as a .bsp path; --game picks the game, --out the pallet folder, and --list prints what the game offers. It prints a summary of what was imported, left out and warned about, and writes it as maps/<map>.import-report.json. It ends with a Timings: line, the total first and then each step slowest first (mounting the game, opening the BSP, the map’s pakfile, each import step), with the process’s age beside the total so runtime startup shows. The timings are printed, never written to the report, and so are the run’s own counts (the files written and left unchanged, the stages reused), so importing the same map again writes the same bytes. The report is the importer’s record, never content: the compiler leaves it out of the compiled pallet along with the .dh-import files. See Importing Source maps.

Terminal window
dh import source-map --list --game gmod
dh import source-map gm_flatgrass --game gmod --out Source

An import does no work twice. Four things see to it:

  • The pallet’s own index. Every pallet an importer writes into keeps import.dh-import, which records what each model and texture was made from (a hash of its source bytes and the version of that kind of conversion). Importing the same map again converts nothing that has not changed.
  • The import cache. What a conversion makes is also kept under the workspace’s cache directory, in import-cache/, keyed by the same hash. A model or a texture converted once serves every map that uses it, a pallet deleted and imported again, and a fresh pallets folder. Each kind of conversion has its own version, so a change to how materials are mapped leaves every decoded model and texture in place. A failed conversion is never kept. The cache is swept like the build’s caches, oldest first and only over importCacheBudgetBytes (Workspace), and --no-cache leaves it out of one import. Point two workspaces’ cacheDirectory at one folder and they share it.
  • The map’s stage record. A map pallet keeps stages.dh-import beside its manifest, which records each bake stage (the lightmap, the light probe group, the reflection cook) by a key: the stage’s id and version, the formats the bakes are written in, and every input the bake reads (the joined geometry, the source files’ digests or their pack’s CRCs, the settings). A stage whose key matches and whose file is still there is not baked again: its file is kept, and its part of the report and its warnings are restored. The map definition is still written in full from the cheap plan of each stage. --no-cache bakes every stage, and still records them. A kept stage’s summary line ends in reused.
  • Many at a time. Models, textures and reflection probes are converted on every core (DH_IMPORT_THREADS caps how many; 1 runs them one after another). The work that decides what is written and reported still runs one item at a time in the map’s own order, so the files, the report and the counts are the same at any width.

The summary says how many textures and models came from the import cache.

dh map reads and edits a map’s authored .dh-map without the engine running. It writes through the level editor’s own source writer, so a camera added here is the same entry an editor save appends, and a move rewrites one value in place: comments, alignment and property order all stay as the author left them. Each write prints the object’s id and how many bytes it replaced.

dh map --help prints:

dh map list <map> [--kind <kind>] [--json]
dh map add <map> <kind> --at x,y,z [--size x,y,z] [--rotation p,y,r] [--name <name>] [--object <barcode>] [--volume <barcode>]
dh map move <map> <id|name> (--to x,y,z | --by dx,dy,dz) [--rotation p,y,r] [--scale x,y,z]
dh map set <map> <id|name> <field> <value>
dh map remove <map> <id|name>
<map> is a .dh-map path or its barcode (<palletId>:<path>). Edits the source; the engine need not run.
add kinds: dh.box, dh.lightProbeVolume, dh.reflectionProbe, dh.camera, dh.light.point, dh.light.spot, dh.light.directional, dh.button, dh.mover, dh.pressurePlate, dh.physicsProp, instance, dh.voxelVolume
set fields: enabled, static, name, connections, light, color, intensity, range, falloff, innerConeDegrees, outerConeDegrees, mode, fov, projection, orthoSize, near, far, aspect, role, thumbnailSize, autoCapture, capturePoint, boxProjection, resolution, importance, blendDistance, probeSpacing, insetProbes, bakeZone, dynamic, probes, contents, bake
VerbWhat it writes
listNothing. One line per object: id, kind, name and position. --kind takes a $type (object), a component id (dh.light, dh.camera, dh.voxelVolume) or an Add-menu kind (dh.light.spot); --json prints an array with each object’s pose
addA new entry at the end of objects, with a fresh id and a name no other object has (or --name). The kinds are the editor’s Add menu, plus instance with --object (written as its source) and dh.voxelVolume with --volume (an object carrying that component). --size is for a box (a button’s, a door’s, a plate’s and a physics prop’s included), a volume, a probe, an instance or a voxel volume
moveThe object’s position (--to, or --by added to the authored one), rotation and scale, each only if given
setOne field, spelled as the file spells it. A field is offered only on the kinds that have it: intensity on a light, capturePoint on a reflection probe, and a presentation component’s fields by its description’s keys, such as fov on an object carrying a dh.camera, which the value goes into. connections takes the JSON array
removeDeletes the object’s entry

An object is named by its id or its name. A name two objects share is refused, and the refusal lists both ids. A number may be negative anywhere a value goes (--by -1,0,0, capturePoint -1,0,2).

Terminal window
dh map list io.mltn.maps.mltn-city:mltn-city.dh-map --kind dh.camera
dh map add maps/yard.dh-map dh.camera --at 0,1.7,-6 --rotation -10,0,0 --name overview
dh map set maps/yard.dh-map overview fov 55
dh map move maps/yard.dh-map overview --by 0,0.5,0
dh map remove maps/yard.dh-map overview

Run dh validate after a batch of edits; dh map checks that a field belongs to the object’s kind and that its value parses, and leaves the ranges to the compiler.

Passing a pallet to dh build compiles just that pallet, together with every pallet it declares as a dependency, transitively. Several pallets compile as one build (one build marker, so the engine reloads once for all of them): dh build a b. Dependencies compile first, so a map is never packaged against a stale version of the material pallet it references.

A single-pallet build reads only what it references. Every pallet.dh in the workspace is opened to find the pallet and its dependencies, but no other pallet’s assets are, so an asset that fails to parse in a pallet the build never references cannot fail it. An error inside that closure still fails the build. An unreadable pallet.dh elsewhere is a warning, unless something the build needs went unfound, in which case it stays an error, since it might be that pallet. The same rule holds for the editor’s Build and for dh watch’s rebuilds, which compile through the same path. Every diagnostic discovery raises names the asset by barcode, so a parse error says which pallet it belongs to:

✗ Failed to parse asset file: Patch is missing required '$type' field
(io.mltn.avatars.taidum-base:taidum.dh-avatar)
Terminal window
dh build dev.example.maps.harbor

The argument may be the full pallet ID or an unambiguous trailing segment, so the above can be shortened to:

Terminal window
dh build harbor

A segment must land on a dot boundary — harbor matches dev.example.maps.harbor, but city matches nothing. If the argument matches no pallet, or is ambiguous, the build stops with a non-zero exit code and lists the candidates rather than quietly compiling nothing.

Running dh with no arguments (or dh interactive) launches an interactive menu where you can choose what to do: build, watch, validate, clean, and more. This is the recommended way to use the CLI for day-to-day work.

FlagShortWorks withDescription
--verbose-vbuild, watch, validate, clean, exportShow detailed output (Blender logs, file-level progress, the export’s fit measurements and studiomdl’s log)
--clean-cbuild, watchClear cache before building (the whole workspace cache, even for a single-pallet build)
--out <dir>build, export, importbuild: write the finished .pallet files to <dir> instead of the workspace’s pallets directory. export: write the export there instead of the target’s default. import: the pallet folder to write; for import minecraft-world and import source-map the pallets root every pallet is written under
--rebuildexportExport even when the output for this content hash already exists
--core <dir>buildBuild the engine’s own core pallet from a repository Engine/content directory
--jsonmap listPrint the objects as a JSON array
--jar <path>import minecraftThe client jar to read, instead of a found one
--version <version>import minecraftThe found client jar to read, by version, instead of the newest release
--game <game>import source-mapThe Source game whose mount stack finds the map: a platform ID or alias (gmod, hl2, cstrike), default gmod
--revision <crc or date>import source2-mapWhich cached revision of an s&box package to import: a CRC prefix of four or more digits (333430fc) or the day it was downloaded (2026-10-02). Without it the newest is imported and a yellow note lists the others, one to a line. See s&box packages
--luxel-scale <n>import source-mapWhat one luxel is in the atlas, instead of the measured 0.98: for the runs that measure the calibration. With --out, which for this verb is the folder every pallet is written under
--listimport minecraft, import minecraft-worldList the client jars found (import minecraft), or the installs and the worlds in them (import minecraft-world), instead of importing
--jsonimport minecraft --list, import minecraft-world --listPrint the listing as JSON on stdout (warnings go to stderr, so stdout is always valid JSON)
--install <game folder>import minecraft-world --list, import minecraftList this one game folder (the one holding saves) instead of every install found. Without it a listing always covers every install; the workspace configuration’s minecraft.instance only picks the install import minecraft takes its jar version from
--root <launcher root>import minecraft-world --list, import minecraftSearch this launcher root too (Prism or MultiMC with an instances folder, a Modrinth app root with profiles, an official .minecraft, or a bare game folder). May repeat. Flags replace the configuration’s minecraft.extraRoots
--sizeimport minecraft-world --listFill each world’s sizeBytes; walks every file, so without it the listing reads level.dat alone
--rewrite-mapimport minecraft-worldWrite the map again even when the pallet already has one (it is kept otherwise), and its thumbnail with it
--forceimport minecraft-worldCopy a world Minecraft may have open. Without it the import refuses
--all-dimensionsimport minecraft-worldCopy the world’s other dimensions too (the nether, the end), not only the overworld
--name <file name>import minecraft-worldThe name the world’s pallet (com.mojang.minecraft.maps.<name>), its container and its map are written under, instead of the save folder’s name in lowercase words joined by dashes
--no-propsimport source-mapLeave the map’s props out: no model is imported and no instance is placed. By default the static and entity props are placed, and their models imported into the pallets that own them
--listimport source-mapPrint the maps the game’s mount stack offers (name, layer, BSP version, size, thumbnail) instead of importing one
--progressimport source-map, import source2-map, import minecraft-worldAlso print machine-readable lines, and print each step’s summary as one in place of the plain log line. progress <step>/<total> <name> as each step starts. progress <step>/<total> <name> <done>/<of> as a step counts its items, with <done>/? when the total is not known yet; a step reports at most about ten times a second and always its final count, and a step with several phases (models, then textures, then placements) counts each from zero in turn. An optional last field is the item most recently worked on, in double quotes with \ and " escaped: progress 6/18 props 412/1880 "models/props_c17/oildrum001.mdl". summary <name> <sentence> as each step finishes, a sentence of real counts ending in its duration: summary props 191 placed (182 static, 9 entity), 21 unique models, 0 missing, 0 failed, 0 left out (0.7 s). Without the flag the same sentence prints as props: 191 placed ... in the plain log. The pallets are the same either way. Studio’s Import page reads these lines for its bar, the current item and the step list; a reader from before counts without a total and items shows such a line as log text
--progressbuildAlso print machine-readable lines in place of the spinner: buildprogress (fraction, stage, pallet, label, file) as the build moves, then buildcompiled for each pallet written, buildfailed for each pallet that failed with its reason (and, when its error points at a place, two more fields: the place as pallet:asset/path or a file, and the line in it or empty; a failure with no place keeps the three fields an older reader takes), and buildsummary with the error and warning counts. Studio runs dh build this way and reads them; the human output is unchanged
--plainall commandsForce non-interactive, plain-text output (no prompts, no ANSI/colors/spinners)

--core builds the engine’s own content instead of a workspace: it takes a repository Engine/content directory, reads no workspace configuration at all, excludes sounds/ and tools/ (source that the engine reads loose, and the generator that authors the geometry), and writes core.pallet beside the core/ folder it compiled. It takes no pallet argument and no --out, since it builds one known pallet to one known place. The build stamp is pinned, so recompiling unchanged sources leaves the committed binary byte for byte identical:

Terminal window
dh build --core Engine/content

A core build is self-contained in the checkout. Its config, caches (.dh/cook-cache, the FBX and mip caches) and saved timings (.dh/build-timings/build-<time>.json and latest.json) live under Engine/content/obj/corePallet/, which git ignores, and nothing is read from or written to the workspace DH_WORKSPACE or dh-config.jsonc names. The core maps’ collision is cooked by the engine host built in the same checkout (Engine/DigitalHeaven.Engine.Host/bin/<configuration>/, the newest build of the two), never by the enginePath a workspace configures; with no engine built the maps ship no cook and the build says so.

--out moves only the output. Sources, dependencies and the build cache still come from the workspace, so a redirected build resolves exactly like a normal one — it just does not replace the pallets the workspace is currently running, which is what makes it safe to compile a map someone has open (a pallet written into the workspace hot-reloads under them).

Example:

Terminal window
dh build --verbose --clean

A flag that isn’t valid for the command it’s attached to — because it’s unrecognized entirely, or because it belongs to a different command (--out on dh watch, say) — is a hard error naming the bad flag and that command’s usage line. It is never silently dropped or ignored, and its value never falls through and gets misread as a positional argument (a pallet ID or file path); nothing is built.

--help / -h works on every command, anywhere in its arguments — dh build --help prints build’s usage and exits without compiling anything. With no command at all, --help / -h (or plain dh help) prints the full command list above.

dh is safe to run unattended — from a script, CI pipeline, or an automation agent — with no terminal attached.

Detection: the CLI automatically treats a run as non-interactive when stdin or stdout is redirected/piped, or when the terminal itself reports no interactive capability. You can also force this mode explicitly with --plain, regardless of how stdio is connected.

Behavior in non-interactive mode:

  • No prompts, ever. Any code path that would normally prompt (e.g. asking for a Blender path) instead fails fast with a plain-text error naming the missing config key and how to fix it, then exits non-zero. The message distinguishes blenderPath being unset from it being set to a path that doesn’t exist:

    Error: blenderPath is not set. Add "blenderPath": "C:\path\to\blender.exe" to C:\Users\you\Documents\DigitalHeaven\dh-config.jsonc.
    Error: blenderPath is set to "C:\path\to\blender.exe" in C:\Users\you\Documents\DigitalHeaven\dh-config.jsonc, but no file exists at that path.

    enginePath — the engine binary a build photographs map thumbnails and cooks mesh collision with — is worded through the same two cases. It is not an error, though: a build with no engine warns and packs the last picture taken of the map, drawing a plain plan of it only when there has never been one, and ships a mesh-mode map without its cook, which then happens at load.

  • No live/ANSI rendering. Colors, spinners, and other interactive rendering are suppressed; output is informative plain text.

  • dh watch skips its interactive keypress controls (Space/Enter/R/C/Q) and simply runs until interrupted with Ctrl+C.

  • Exit codes are consistent everywhere: 0 on success, non-zero on any failure (validation errors, missing config, exceptions).

dh watch monitors your Source/ directory and recompiles when files change.

Debounce: waits for changes to settle before compiling (default: 1000ms, configurable via watchDebounceMs).

Manual controls:

KeyAction
Space, Enter, R, CTrigger manual compile
Q, Ctrl+CExit watch mode

dh info displays metadata embedded in compiled .pallet files:

Terminal window
dh info Pallets/dev.example.avatars.mayu-custom.pallet

Output:

dev.example.avatars.mayu-custom.pallet
╭──────────────────┬─────────────────────────────────────╮
│ Property │ Value │
├──────────────────┼─────────────────────────────────────┤
│ ID │ dev.example.avatars.mayu-custom │
│ Name │ Mayu Custom │
│ Version │ 0.0.1 │
│ Author │ example │
│ Built │ 2026-02-05 10:30:00 -08:00 │
│ DH Version │ 0.1.0 │
│ Files │ 42 │
│ Size │ 2.4 MB │
│ Uncompressed │ 3.1 MB │
│ Dependencies │ tech.azuki.avatars.mayu ^3.0.0 │
│ Tags │ avatar, character │
╰──────────────────┴─────────────────────────────────────╯

If no file is specified, dh info shows info for all pallets in the build directory.

Building dev.example.avatars.mayu-custom...
✓ Parsing definitions
✓ Validating references
✓ Processing textures
✓ Converting models
✓ Bundling assets
✓ Writing output
Build succeeded in 1.2s
Building dev.example.avatars.mayu-custom...
✓ Parsing definitions
✗ Validating references
Error in dev.example.avatars.mayu-custom/avatar.dh-avatar:12:5
Missing required property 'name' in material definition
10 | "materials": {
11 | "body": {
> 12 | "diffuseTexture": "textures/body.png"
| ^
13 | }
14 | }
Build failed with 1 error

dh build with no pallet argument compiles every pallet in the workspace. A pallet whose own validation or packaging produces an error is skipped — its output is not written — but that never aborts the rest of the run: every other pallet still compiles and writes its .pallet file. The command still exits nonzero, and the failed pallets are named at the end so a partial build never reads as a clean one:

Pallet: dev.example.materials.broken
✗ Unknown material field 'notARealField' (dev.example.materials.broken:materials/broken.dh-mat)
= Note: in 'Broken' (dev.example.materials.broken:materials/broken.dh-mat). A dh.material's fields
are closed. Valid fields: ...
[green]Compiled 2 pallet(s) to:[/]
- .dh/Pallets/dev.example.avatars.mayu-custom.pallet
- .dh/Pallets/dev.example.props.streetlamp.pallet
Failed to compile 1 pallet(s):
✗ Unknown material field 'notARealField' (dev.example.materials.broken)
✗ Build failed

A pallet that declares a dependency on a pallet which failed this run is skipped too, with its own reason (Skipped: depends on '<id>', which failed to compile) rather than being compiled against missing or stale output. This dependency check is exact for dh build <pallet> (a single pallet and its dependencies, which always compile dependency-first); for a full, unqualified dh build it only catches a dependent that happens to be processed after its failed dependency, since a plain workspace build has no dependency ordering to begin with.

Before writing a pallet’s .pallet file, dh build compares the freshly packaged content’s fingerprint against what is already on disk (the same fingerprint dh info reports, which excludes the build timestamp so a content-identical rebuild reads as identical). The fingerprint DOES fold in the format version of every generated companion (cooked collision, lightmap UVs, GI volumes, reflection cubemaps, mip chains, sky images) plus the compiler’s own version, so bumping any one of those always invalidates the “unchanged” check — a format change is never mistaken for a content-identical rebuild, even when no source file was touched. When the fingerprints match, the file is left untouched — not rewritten with new metadata — and the pallet is named in its own summary section instead of the compiled list:

[green]Compiled 1 pallet(s) to:[/]
- .dh/Pallets/dev.example.maps.harbor.pallet
2 pallet(s) unchanged:
- dev.example.assets.ambientcg
- dev.example.assets.sbox

This matters beyond saved disk writes: a workspace’s engine client hot-reloads a loaded map whenever one of its pallets — or a pallet it depends on — is republished, by watching the filesystem. A dependency pallet that never touches disk never fires that watcher, so a dh build that only actually changed one pallet triggers exactly one hot-reload instead of one per pallet in its dependency chain that happened to get recompiled alongside it. Every build also leaves a marker file, .dh-build-<id>.json, in the pallets directory it writes; the engine waits for it to say the run is complete and reloads once for the whole build (see Map hot reload).

The fingerprint check above decides whether to write, after the whole pallet has been packaged. A build also remembers, per pallet, what it was last compiled from, so a pallet nothing has touched is skipped before any work starts. A pallet is skipped only when all of these hold:

  • every file under its folder has the same path, size and write time as at the last successful compile (the pallet.dh manifest counts by what it says instead, leaving out the importer’s importer* metadata stamps, so an importer version bump that rewrites only those compiles nothing, and an importer’s own records, its .dh-import files and a map’s .import-report.json, do not count at all),
  • every pallet it declares as a dependency, transitively, is just as untouched,
  • the .pallet that compile wrote is still on disk with the content hash it recorded, and
  • every compiler stage its own files go through is at the same version, and the Blender install is the same when an FBX is anywhere in it, the workspace’s texture encode effort for it when that is not the default, and for a pallet with a map in it, the engine install too.

The compiler is split into stages, each with its own version in CompilerStages, the one place they live:

StageWhat it makesA pallet goes through it when it holds
PackingThe container: entry layout, LZ4, the headeranything
Definition$type, resolved and rewritten paths, compiled objectsany definition file
Texturedh.texture images, mip chains, the UASTC encodean image or a .dh-tex
ModelFBX conversion, the .dh-model patch, tangent repair, stand-insa .glb, .gltf, .fbx or .dh-model
AvatarEye heights, footprints, fitted spring collidersa .dh-avatar
MapBaked colliders and the collision cooka .dh-map
MapLightingThe lightmap, probes and reflections as packageda .dh-map
ThumbnailA map’s photograph, an avatar’s picturea .dh-map or .dh-avatar
VoxelConverted voxel payloadsa .dh-vox

A change that makes the same sources compile to other bytes bumps the stage it changed, and nothing else. The fingerprint folds the version of each stage a pallet’s files go through, with the versions of the companion formats that stage writes, so a bump compiles exactly the pallets that stage touches: an avatar change leaves every map and texture pallet alone. CompilerStagesTests compiles a fixture with an entry from every stage and fails, naming the stage that makes the entry that moved, until the bump and the new checksum are recorded together. The compiler’s binary is not part of the fingerprint, so rebuilding the toolchain leaves every untouched pallet alone. Only a map pallet answers to the engine’s binary, since its collision is cooked and its picture taken by the engine and nothing versions what an engine change does to either.

The content-addressed caches the build reads (texture encodes, stand-ins, collision cooks, map photographs) file each entry under the version of the stage that makes it, from that stage’s first bump on: a texture bump misses every encode the old texture stage made and keeps every cook.

A pallet that compiles again keeps what did not change

Section titled “A pallet that compiles again keeps what did not change”

When a pallet does compile again, because one of its files changed or a stage it uses was bumped, every file that is not a definition (an image, a model, a sound, any file shipped as it is) and whose inputs did not move is carried from the pallet the last build left, byte for byte as that build stored it: no source read, no encode, no compression. A file’s inputs are its path, size and write time, the version of every stage it goes through, how entries are compressed and, by kind, what the build decides from outside the file (a texture’s settings, mip recipe, encode row and basisu; a model’s .dh-model sidecar and stand-in). The previous .pallet is the store, so carrying costs no second copy of a large pallet on disk, and its warnings are replayed with it. dh build ends a carrying build with Assets: N carried from the previous build, M compiled., and the build timings name the time as carry.

An entry records the shape it was written in (formatVersion), which is never folded into a fingerprint. An entry of an older shape is migrated when it is read, so a change to the cache’s own format keeps every pallet it vouched for instead of compiling the workspace again. The entries from before stage versions migrate when they still match what the one-number recipe they were made under would fingerprint.

A file the compile itself writes into a pallet’s folder (a model’s .dh-ids node-identity sidecar, a map’s photographed .thumbnail.png) does not count as a change while it is still exactly what the compiler left: the build that writes one does not make the next build compile the pallet again. Edited by anything else, it counts like any other file, and one that went missing counts as gone.

A skipped pallet is listed with the unchanged ones, and the warnings its last compile raised are replayed, so a quiet build is not made of pallets whose warnings went silent. It is a stat of each file, never a read, but for the manifest. A file written in the last two seconds is not trusted to have settled, so a pallet being edited right now is recorded by the build after it stops. A failed pallet is forgotten and tried again. dh build --clean deletes the cache directory the memory lives in (build-cache/).

Every build ends with where its time went:

Build timings: 1m 07s total, 12 pallet(s) compiled one after another
Slowest pallets:
35.7 s com.valvesoftware.hl2 (unchanged)
11.5 s com.steamcommunity.workshop.326332456 (unchanged)
Slowest stages, summed across pallets:
23.5 s mips (35%)
17.3 s compress (26%)
Slowest assets:
5.8 s com.steamcommunity.workshop.326332456:maps/gm_fork.dh-map (assets)
Full breakdown: <workspace>/.dh/build-timings/build-20261004-112746.json

Pallets compile one after another, so a pallet’s time is its wall time. The stages never overlap: a stage is credited only the time it spent outside the stages opened inside it, so a pallet’s stages add up to its wall time, and what no stage names is other.

StageWhat it is
resolveReading the workspace, every pallet manifest and every definition file, a pallet’s files parsed several at a time. Once per build, before any pallet is skipped, so it is most of a build that compiles nothing.
fingerprintStatting a pallet’s files to see whether it needs compiling.
validateChecking a pallet’s assets, components and references.
collectWalking the pallet for the files to package.
texturesdh.texture operations and @channel extraction.
prepareAvatar measurements and checks, texture sidecars.
assetsThe per-file loop itself: reading, rewriting, PNG repair.
modelImportFBX to GLB through Blender.
modelPrepA model’s patch ops and glTF check.
mipsBuilding a texture’s mip chain. The chains are built several at a time a little ahead of the file loop, so this is mostly the loop waiting for one.
compressChecksumming and LZ4-compressing into the data blob.
collisionCookCooking a map’s collision in the engine host.
lightmap, probes, reflectionsPackaging a map’s baked lighting.
voxelConverting a voxel definition’s source.
thumbnailsPhotographing maps in the engine.
seal, writeFingerprinting the pallet, and publishing the .pallet.

The summary is plain text and reads the same under --plain or in a redirected log. --verbose adds every pallet’s own stage breakdown and the full list of the ten slowest assets. Only the ten slowest assets are kept, in a bounded buffer, so a build of a hundred thousand files holds ten.

The full breakdown is saved as JSON under .dh/build-timings/ in the workspace (build-<time>.json and a latest.json, the newest thirty kept), so a later run can be compared with it: the per-pallet wall time, every stage’s time per pallet and summed, and the slowest assets.

What dh build does under the hood:

  1. Parse — Read the JSONC/.dh-* definition files of every pallet the build compiles or depends on
  2. Validate — Check schemas, references, types, inheritance chains
  3. Process textures — Channel packing, extraction, and resize from dh.texture definitions and @channel references
  4. Convert models — FBX → GLB via Blender (cached by content hash)
  5. Inspect map geometry — Open each dh.map’s GLB and report what the engine’s importer would report at load: refused primitives and mismatched attributes as errors, unbound material slots, skinned nodes, morph targets, animation tracks and degenerate-heavy meshes as warnings. See what the build says about the geometry.
  6. Cook map collision — Hand each mesh-mode dh.map’s triangles to the engine host (enginePath), which cooks them with the runtime’s own cooker into the map’s .dh-colcook companion. Cached per map, so an unchanged map starts no engine
  7. Bundle — LZ4-compress assets into the output pallet
  8. Output — Write compiled .pallet file to Pallets/

A build packages bakes; the editor makes them

Section titled “A build packages bakes; the editor makes them”

Compiling stays light: a bake that takes a GPU and minutes is made in the editor, and its result lives beside the map source as a file the build packages as it stands. A missing or stale one is a warning and a map without that bake, never a failed build. Staleness is judged against the map’s geometry, which each bake fingerprints.

BakeMade byFile beside the sourceStale when
LightmapMap ▸ Lighting ▸ Render Bake, lightmap.bake, --bakeLightmaps<map>.dh-map.dh-lightmapthe geometry changed
Reflection probesMap ▸ Bake all reflections, a probe’s Bake, reflection.bake, --bakeReflections<map>.dh-map.dh-reflcookthe geometry changed, or a probe moved, resized, appeared or went

The GI volume and the cooked collision are still computed by the build. The cooked collision is computed in the engine host the workspace names, as a child process, so the compiler itself never loads the engine; a cook is cached per map, and a build with no engine ships the map without one.

PlatformBakes in the editorHeadless bake flagsLoads the packaged bakes
Engine, Windows desktop✅✅✅
Engine, Linux and macOS desktop❔❔❔
Engine, iOS❔❌❔
The game platforms❌❌❌