Skip to content

Halcyon Studio

DigitalHeaven Studio is being rebuilt on Halcyon. Until it reaches parity it lives at Studio/DigitalHeaven.Studio.Halcyon, beside the Avalonia Studio, which keeps its path so Rider keeps running it. The rename (the Avalonia project to DigitalHeaven.Studio.Avalonia, this one to DigitalHeaven.Studio) is a separate step. The window is titled DigitalHeaven Studio.

Phase 1 is the shell, onboarding, Home and Platforms. Sources, Built and Import are real too: Built is described below and Import below that.

The app is its own process and references nothing under Engine/. It builds pallets with the Compiler, which reaches no Engine project either: a map’s collision is cooked by the engine host as a child process, and only when the workspace’s enginePath is set.

PieceWhere
The window: remembered size, place and maximized state, live redraw while an edge is dragged, the scale arithmeticHalcyon.Sdl’s SdlWindowShell, shared with the XR overlay’s settings window
The Vulkan surface over an SDL windowHalcyon.Sdl’s SdlVulkanSurface, shared with the engine host
PresentingHalcyon.Vulkan’s SwapchainPresenter: acquire, draw one DrawList with HalcyonImageRenderer, present, rebuild on a size change or out-of-date
Pointer, wheel, keys, clipboard, logCursorShapes, SdlWheel, SdlKeys, SdlClipboard, AppLog, all in Halcyon.Sdl
The UIStudioShell builds the whole window as one widget; Halcyon’s UiSurface (the same surface the XR overlay’s panels are) feeds it the pointer and keys and asks for a draw list only when something changed

The window sleeps between events and wakes for a background read or an animation. --capture <folder> renders every screen headlessly from this machine’s real workspace and platforms, and writes nothing but the pictures and its log. --behind opens the window behind the others without taking focus, for an automated run.

The app keeps its own files (settings.json, window.json, layout.json, latest.log) in %APPDATA%\DigitalHeaven\Studio, or wherever DH_STUDIO_CONFIG points. It never writes the Avalonia Studio’s dh-studio.jsonc. Every tuning number is in settings.json: the narrowest a tile gets, the divider’s thickness, the narrowest a pane gets, the window’s save delay, VSync, and whether the menu bar shows.

The Start Menu shortcut DigitalHeaven Studio runs scripts/launch.bat, which builds Studio and dh together in Release (one dotnet build of scripts/launcher.slnf, so the projects they share compile once), publishes the Source 2 module beside dh when its own inputs changed, and then starts Studio, so an import and the build after it always use the same toolchain. scripts/build-if-changed.ts (the fingerprint the engine host build uses too) skips each dotnet call when nothing it is built from changed since its last good run, so an unchanged launch takes a second or two; the module has its own fingerprint, which includes the Core it shares with dh. Any doubt builds, and without bun the launcher builds every time. Each step logs one line saying whether it built. scripts/shortcut.ps1 creates the shortcut. It never touches the Avalonia Studio or its DH.Studio shortcut. See CONTRIBUTING for the details.

SDL3 on Windows places a mouse button where it last read the system cursor, and reads the cursor again when it sees the pointer leave. A click posted to a window kept behind the others (an automated run’s) therefore landed wherever the real cursor was. Halcyon.Sdl’s WindowsPointerMessages reads each mouse message’s own point from SDL’s queue, and SdlWindowShell.PointerPixels prefers it; for real input the two points are the same. A key sent to a window without keyboard focus names no window in SDL, and the shell takes it. DH_STUDIO_TRACE_INPUT=1 logs every key and mouse event SDL delivers.

A top bar with the wordmark, the File, Edit, View, Build, Window and Help menus, the workspace path (selectable), and ⋮. A rail with Home, Platforms, Sources, Built, Import and Settings; a dot marks a mode that needs you. A status bar with what last happened on the left, and the changed pallets and errors on the right. While a job runs, the left side is that job in full ink: a small progress bar, what it is doing (Building MltnCity · Building io.mltn.assets.ambientcg first (MltnCity uses it) · 2 queued), and a click on it brings the Queue tab to the front. The error count is the workspace’s problems plus every build and import that failed and has not worked since, and it is never lower than one while the left side reports a problem, so the two halves never disagree. A command the app cannot do yet is in its menu and disabled.

A failure is never only a line in the status bar. A failed import or build also raises a notification card (StudioShell.ShowToast with problem: the danger tint, wrapped, and up for ProblemToastFactor times the usual time; cards stack in the bottom-right corner clear of the status bar, a click dismisses one and the pointer on one holds its timer, see ToastStack in Halcyon), tints that map’s row and its Home card (never a side stripe), and puts the whole message with Copy and an expandable raw log on the map’s page; see Import.

Home and Platforms each lay their panes out with Halcyon’s dock brain, the same DockTree and DockLayoutSolver the engine editor uses. The divider between panes drags; Window → Swap panes mirrors them and Stack panes turns a row into a column. Every layout is one line of DockLayoutText in layout.json. Reset layout is under ⋮ and under Window.

Every pane wears the editor’s pane chrome (DockChrome.Pane: the bordered plate, a tab strip, a close chip). A tab drags: inside its strip it reorders, pulled past dockTearThreshold it becomes a ghost and the drop indicator shows what letting go does (join another pane’s tabs, split a pane on a side, split the whole area on an edge). The drag is DockTabDrag in Halcyon/Docking, host-agnostic over a DockTree and the resolver the editor uses; the editor’s own copy still lives in its shell, with floating windows, and has not adopted it. The first pane of a mode cannot be closed; the others close from their chip or the Window menu, which lists every pane with a check and reopens a closed one along the right edge. A pane cannot be torn into its own OS window: the editor does not do that either, only floating windows inside its own, and Studio has none. The drag tuning (dockEdgeBand, dockCenterFraction, dockPaneSplit, dockRootSplit, dockTearThreshold, dockIndicatorAlpha) is in settings.json.

Every mark the Studio draws is a Material Symbol (Rounded, weight 400, grade 0, optical size 20, with the current rail mode filled), baked to masks under Engine/content/ui/symbols, the one set the engine shares (UiSymbols names every slot), and drawn through VectorGlyph.FromMask; PROVENANCE.md there names each slot’s symbol. The browser’s fold chevrons take the same marks through ToolbarPalette.FoldOpen and FoldShut; the engine passes none and keeps its text chevrons.

Two asterisks follow a name wherever Sources draws one. Yellow means edited and not written: the Raw text differs from the file. Blue means it needs building: the pallet was never built, or the file was written after the pallet’s build. A name that is both wears yellow first, then blue. Each asterisk has its own tooltip that says which it is and what to do: “Unsaved changes. Ctrl+S saves.”, “Source changed since the last build. Build to update.” or “Never built. Build to create it.”

They show on the pallet list rows, the browser tree’s pallet rows, the browser’s file rows and tiles (and a pallet at the workspace scope), the Inspector and Raw tab titles for the selected file, and the wordmark in the top bar for unsaved (the OS caption cannot be colored). Inspector edits write at once, so Raw is the only place an edit waits. A file is blue only when it is newer than the build, so the mark says what changed rather than everything beside it. A build that ran and failed is not an asterisk. The two roles are StudioBlack.Unsaved and StudioBlack.NeedsBuild; the tooltip is Halcyon’s HoverTooltip, with its dwell and wrap width as tooltipDwellSeconds and tooltipWidth in settings.json.

With no dh-config.jsonc at the default workspace folder, the app opens on Set up a workspace: the folder, its source and pallets folders, and the games found on this PC. Create workspace goes through Core’s WorkspaceManager, as the CLI does: the config is written at the default location (pointing at another folder when one was picked), and the folders are made. Open an existing workspace points the config at a folder that has a dh-config.jsonc or a Source folder. Folders are picked with SDL’s folder dialog.

Setup also runs the Compiler’s WorkspaceGenerator, as the Avalonia Studio did: the convenience script, the git files and the VS Code files.

Home’s main tab is Home (StudioShell.FeedTitle; its window id is still home.queue, which saved layouts spell). It comes from what is on disk:

  1. An update to a platform’s DH mod, once the mod library holds a newer version.
  2. A pallet whose source is newer than its build, from Core’s PalletFreshness: every file under the pallet’s folder against the built <id>.pallet (dot folders are skipped).
  3. An import or a build of a map that failed and has not worked since: Import of <map> failed or Build of <map> failed, on a red-tinted card with Try again. It goes first, ahead of the updates.
  4. The import or module install that is running.
  5. A map an importer made with an older importer than the one present (Stale on the Import page): Re-import <map>, one item per stale map, gathered into one group card per origin game when a game has two or more (see below). A stale map is never also asked to build: a re-import builds what it writes, so its “changed since its last build” item is folded into the re-import (HomeQueue.Build), which then opens with Source edited since its last build, and re-importing writes over that edit.
  6. The Source 2 importer module, when it is not beside dh and something wants it (Counter-Strike 2 is installed here, or a Source 2 map is in the workspace): Install the Source 2 module, or Update the Source 2 module when it is there but was published from other code than dh runs.
  7. A pallet that was never built.
  8. A game that is here without its DH mod.

Builds, imports and module installs are jobs in one queue (JobQueue, on StudioShell.Jobs), shared by Home, Sources’ build menu, the Built page and the Import page. Jobs run one at a time in the order they were asked for; a Build, Re-import, Re-import all, Build all or Do all asked for while something runs waits its turn, and no button greys out because something else is running. Asking again for a job that already waits or runs adds nothing. Do all, Build all and Re-import all ask for each member as a job of its own, so each card shows where it stands; a failure ends only its own job.

A card wears its job’s state in place (QueueView.Shown, QueueView.JobAction): waiting, it reads Queued · 1 of 2 in a neutral badge with a Cancel that takes it out of the queue; running, its Cancel is the ProgressReveal with the whole job’s progress in it, and its line says what the build is doing. dh build --progress names the pallet every report is about, dependencies included (the Compiler begins a slice for each pallet it compiles), so a pallet the build was not asked for is one it builds first, and the workspace’s manifests say who uses it: Building io.mltn.assets.ambientcg first (MltnCity uses it) (BuildSteps). The fraction covers the whole build, dependencies and all. When a job ends, the card goes back to its own state, or to the failure card.

The Queue tab (JobsView) lists the running job with its step and progress, the jobs waiting with their place, and the jobs that finished (the newest last, up to twenty), each with Cancel, or Retry for one that failed or was canceled. By default it is the second tab beside Overview. The layout file keeps each area’s windows (home.windows), so a window added later joins a saved layout where the default puts it, as a tab beside a window of its default pane, while a window a person closed stays closed; a file from before that key knew every window but the ones an area marks Introduced.

A pallet’s Build runs the Compiler on it and what it depends on, the same CompilerService as dh build, and the workspace is read again afterwards. Map cooking under the workspace’s facts says whether builds cook: the engine host enginePath names, or Off · maps ship uncooked with the Compiler’s own reason.

The queue is calm cards: neutral, with a rounded-square well tinted in the item’s state color (the card’s own fill pulled toward amber, blue or red, never a fixed purple) that holds the game’s square icon (Steam’s <appid>_icon.jpg, then the executable’s own icon, then the cover) and a state badge as the one loud element. Every action is the one button; a click anywhere else on a card opens what it is about (the platform, or the pallet in Sources). A game’s stale maps are one group card, 3 stale Garry’s Mod maps, on the queue card with the game’s square icon, a count and the Stale badge (HomeQueue.Grouped; a game with one stale map keeps its plain card). Grouping is by origin game, so Half-Life 2’s maps are their own group. Re-import all queues each map as a job of its own. While one of the game’s maps imports, the group reads as that import (Re-importing · 2 queued) with one Cancel that stops the running map and takes the game’s waiting maps out of the queue; the maps waiting read Queued · 2 of 3 with their own Cancel, and are Stale again once they leave the queue. The Import page’s rows say the same. A click on the card body expands it in place, the header unchanged, to compact rows, each with the map’s own picture when its pallet holds a maps/<name>.thumbnail.png (nothing extra when it does not), its state and its own Re-import or progress. Open groups are kept per group for the session (StudioShell.ToggleQueueGroup). The stale items reuse the Import page’s own detection (ImportedMaps, ImportedMap.State) and its own job: Re-import calls StudioShell.StartImport and Install calls InstallSource2Module, so the process, the progress lines and the cancel are the Import page’s, and a click on the card opens that map or game on the Import mode. While one runs the stale item steps aside for an importing card of the same job. A running import (the Import page’s job) is a card of its own with no progress band on the card: its Cancel is a ProgressReveal (see Halcyon), the quiet danger button with a second whole button in the busy role revealed over the left share of its width, with its own rim and a dark label. A child row inside an open group keeps its inline progress bar, whose track and fill follow the shape sheet’s Bar radius (3 on Rounded, square on Square). The card says the percentage and the step; it appears only while dh reports.

The right-hand pane holds the workspace’s facts, the platforms and the imported content (every recipe whose pallet is built). The platforms are compact rows of one split button each: the body opens the game on the Platforms page, and the right segment, Play, is the only part that launches, at the body’s own height and with its own border. A state shows only where something is wrong (an update waiting, the game missing). The DigitalHeaven engine sits above them as a larger card of the same split button: its mark, the host’s file name, the host’s folder behind the body and Play on the right; where the host the workspace names is not on disk the card is gray, says Not found and has no actions.

The layout is locked. While nothing is selected the list pane shows the grid: a tile per platform, found games first. Selecting a tile shows the list (needs you, ready, not found) with each row’s main action inline, beside the platform’s neutral detail. Esc goes back.

A tile shows the Steam cover, the state, the selectable name, platform ID, folder and loader, the content row, the main action joined to uninstall, and the outlined Play. Its folder buttons open the game’s folder and its own data folder (AppData\LocalLow, the game’s data or the launcher instance). The detail adds the DH mod’s facts, the content pallet’s build time and the last DH lines of the game’s log.

WhatHow it is found
Steam gamesCore’s SteamLibrary: the Steam roots, libraryfolders.vdf, then each library’s appmanifest_<id>.acf for the folder
CoversSteam’s appcache\librarycache: library_600x900.jpg in the app’s folder, a hashed folder inside it, or the older <id>_library_600x900.jpg
MinecraftThe Prism instance holding a digitalheaven-*.jar (its Fabric and game versions from mmc-pack.json), else the official launcher; the cover is built from the client jar’s block textures
The loaderThe files the platform docs name: BepInEx\core\BepInEx.dll (5) or BepInEx.Core.dll (6), MelonLoader\net6\MelonLoader.dll, ZombieBuddy.jar; versions from the DLLs
The DH modThe mod’s file in each platform’s install folder (BepInEx\plugins\DigitalHeaven\…, Mods\…, garrysmod\addons\digitalheaven, System\DigitalHeaven.u, the Zomboid mods folder); its version from the DLL or the jar’s name
ContentThe recipe’s pallet in the workspace’s pallets folder
PlayThe game’s executable when the catalog names one, so Steam asks nothing; otherwise steam://rungameid/<id>. Minecraft opens its Prism instance. Nothing is ever started without a click, and SteamVR is never started on its own

Installing, updating and removing go through one interface, IModLibrary, which lane/distributables’ offline library of mod versions (manifests, install maps, receipts) will implement. Until then the library holds nothing: Update never shows, and Install and the trash are shown and disabled. Locate game folder and Add a platform are disabled too, until there is somewhere to keep a folder the person picked. The importer row is a state only.

The workspace’s pallet sources, in the two layouts mltn picked between. A Layout switch in the toolbar changes them and both are kept, and a Pallet names switch (Name or Barcode) says what the pallet list and the browser’s tree call a pallet and which way they sort; it is palletNaming in settings.json and defaults to Name. The toolbar is one row of 30-pixel controls on a bar only a little taller, all on one center line. Every pane is a tab of the mode’s dock, so each can be tabbed into another pane’s strip, moved or resized, and Reset layout (under ⋮ and Window) puts the current layout’s default back. The layout choice and both trees are in layout.json.

LayoutPanes
List + treePallets, then Files with Build log, then Inspector with Raw and Diagnostics
Merged browserFiles with Build log over Preview, then Inspector with Raw and Diagnostics
  • Pallets groups only Source and Imported. A source pallet states its version and, when it needs one, Changed or Not built. An imported pallet wears the game’s own cover as its tile. Selecting a source pallet scopes the browser to it. The list is a VirtualList of row recipes, so only the rows in view are built.
  • Files is the shared library’s BrowserPane, filled from the workspace: the pallets are its roots, and the library’s AssetCatalog (over the workspace’s source folder and the runtime catalog of built pallets) walks a folder only when it is opened, keeps it, and answers the file watcher’s changes once the workspace has been quiet; browserSearchDepth, browserRefreshSeconds and browserRefreshMaxSeconds in settings.json tune it. The list layout draws it at the list rung and the merged layout at a grid rung. In the list layout the tree is rooted at the picked pallet and lists nothing else (BrowserContext.TreeScopedToPallet; with no pallet picked it says so); the merged layout keeps the whole workspace in the tree. The divider between the tree and the listing drags (the same BrowserSplitGesture the level editor uses) and its width is browserTreeWidth in settings.json, written when the drag lets go, bounded by browserTreeMinWidth and browserTreeMaxFraction. A name longer than the tree is not cut: the tree scrolls sideways under a horizontal bar pinned to the bottom edge of the tree’s viewport (VirtualList.Horizontal, shown only while a row overflows). A texture’s thumbnail is the image itself (or the image beside a .dh-tex), decoded in process by SourcePictureSource.
  • Inspector is the library’s InspectorWindow.BuildBody over StudioInspectorAdapter: one column with one scroll. The thing inspected is one owner of an object or avatar file, picked at the top: its root, a part some layer overrides (Armature/Body), or a record it adds (the turtle neck). It is read through Core’s ObjectResolver, so every card is the merged component with what the layers underneath resolve it to beside it: a field that departs wears the mark and its inherited value ghosts. Every component is a generated card (dh.eyes, dh.springBone on a chain’s root part, dh.renderer with its materials by slot name and its shapes by blendshape name as map editors, dh.armatureLink, one dh.reaction card per event and tier with its actions as a list of records), keyed types addressed by ComponentAddress (dh.reaction#damaged|big). Studio adds a play button under every reaction’s clips (ReactionClips, an extension) and one hand-made card, the humanoid bone map of dh.skeleton (SkeletonCard), drawn only when the rig maps humanoid bones. An avatar’s fitted spring colliders are dh.springCollider cards over the fit, so a radius written names only the collider’s id and the radius. A write through InspectorWrites becomes a new text for the file through Content’s ObjectSourceEditor, the one component writer the level editor’s map saves use (MapSourceComponents.Rewrite): a map entry or a block field is written inside its object where it stands, a revert takes the key out, and a part override is made when a part is first departed in and taken out when it is left with nothing but its address. Undo and redo (the Edit menu, Ctrl+Z, Ctrl+Y) put the file back to its previous text byte for byte. A file that still spells a patch list is shown read-only with the migration named. The header’s name writes the definition’s name.
  • Under the cards, inside the same scroll (BuildBody’s trailing parameter), an avatar’s Proportions show what the last build generated.
  • Raw is the file’s text in Halcyon’s MultilineText: caret, selection, scroll by wheel and caret, clipboard, undo and redo, the I-beam. Save or Ctrl+S writes it through AtomicFile when it reads as a definition (JSON with comments and trailing commas, an object at the root). A text that does not is not written: the pane says so and the problem, with its line, is the first row of Diagnostics. The file is read as LF text and written back with the line endings it had. A card edit while the text holds edits of its own leaves them and warns that Save would overwrite the change. Selecting another file drops unsaved edits.
  • Diagnostics runs the Compiler’s Validate off the frame when the mode opens and after every build or save, and lists what is about the selected pallet.
  • Build and Auto build use the same CompilerService as Home. Build and its chevron are one split button: Build builds the selected pallet, the chevron’s menu offers the changed ones, and Auto build builds a pallet when one of its definitions is saved. Build log lists each pallet as it starts and every warning and error the build reported.

Opening Sources is cheap because nothing heavy runs on the frame. The asset catalog (finding every pallet, the newest write of each pallet’s tree, listing each pallet’s root) is opened on a worker as soon as the workspace is read, with the per-pallet walks side by side, and the Files pane says it is reading until the catalog lands. On the real workspace (49 pallets) the first Sources frame cost 1.2 s on the UI thread; now the UI thread never waits on the catalog, and the first frame that draws it, with an avatar’s inspector in it, is about 0.2 s.

The window asks SDL for the first click: the press that focuses Studio from another app is also a click (SdlWindowShell.ApplyHints, SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH), as it is for every desktop app composed on the shell, including the XR overlay’s settings window. The engine host has its own SDL runtime and is not changed.

Studio never writes to a workspace on its own: the capture only reads one, and a test writes only to a scratch folder.

Not here yet: live 3D orbiting of a preview (avatars, objects and materials draw as still pictures from the preview engine; see Previews), platform reaction layers (the resolved list shows the chain alone), the head capsules’ overrides, and a search over the workspace.

  • Owners. The inspector’s Showing picker lists the root, every part of the object’s model, and every record the file adds. The parts are the build’s part table of the model: read from the model when it is a glTF container a source pallet holds, and from the built pallet’s converted copy when the build converts it (an FBX). Picking a part no layer overrides opens an empty override that the file states only once something is edited. A part an override names that the model no longer has follows the model’s own; a model neither source can read falls back to the parts a layer overrides.
  • Ghosts. An inherited value ghosted beside a text row is the one cell that gives way: it shrinks to an ellipsis, the whole value is on the plate the pointer raises, and the box keeps a floor (AuthoringMetrics.TextRowMinWidth) instead of being squeezed to nothing. The rule is in InspectorWindow.TextRow, so the level editor’s inspector and both Studios share it.
  • Orphans. When the model’s identity sidecar holds identities whose nodes are gone (the build’s nodeIdentity orphan warning), an Orphans section under the cards lists each with the path and name it was last seen at and the overrides in the file that wait on it. Retarget onto names the live node it became (nearest candidates first); that sets the orphan’s retarget in the sidecar through MapNodeIdentitySidecar.WithRetarget, the one writer, and the next build honors it and clears it. The write shares the file’s undo stack, and Auto build builds after it as after a save. An object or avatar reads the sidecar beside its own file; a map, the one beside its world’s model.

Every pallet with a source here or a built file in the pallets folder, as a ledger: its state (Failed, Changed, Not built, Current, or Built only for a pallet with no source here), its name and barcode, what its file serves (every platform, plus a badge for each platforms/<id>/ override folder in its source), its size, when it was written, how its source compares and how long its last recorded build took (.dhuild-timings). The state row above it filters the ledger by state, each with its count. Picking a row opens that pallet’s page in a second docked pane (built.detail) beside the ledger behind a divider that drags; the ledger drops its wide columns while the page is open, and the page’s X closes it. The page lists the output file, its size, what it serves and where the build’s time went as one bar of stages, the source folder and the newest edit, and the platforms with a DH mod installed (every runtime mod reads the pallets folder directly, so nothing is deployed to them).

Build builds that pallet (filled when it needs a build), Show in folder opens its folder, Open Pallets folder and Build N changed are in the header. Failed is a build that ran in this session and failed: the compiler’s reason is on the row and the page, and a later build that succeeds clears it. Nothing on the page is invented, so with no failed build the filter shows none. Export to a platform has no backend yet and has no button.

The Import mode lists the games whose importers exist (Half-Life 2, Garry’s Mod, Counter-Strike 2 and Minecraft), each with its own square icon from the Platforms list, and, under each, the maps the workspace holds for that game with where they stand, plus any other map with an import of it running, queued or failed; beside the list is the page of the selected game or map. The list has no cap. The list and the page are the mode’s two docked panes (import.list, import.page). It shows only real data: a map appears once its pallet is in the workspace, or once a listing of the game’s maps found it, and the maps a listing found but nothing imported are browsed on the game’s page (see The map browser).

StateMeaning
Not importedA listing found the map; nothing of it is in the workspace
ImportedThe pallet’s manifest names an importer and carries that importer’s current version
StaleIts importerVersion is older than ImporterVersions says, or it has none
ImportingA dh import for it is running; the row carries a progress bar

The signal is the pallet’s own manifest: metadata.importer, importerVersion and game (see Importing Source maps), so Studio and the importer agree through one pair of constants in Core. A map is listed under the game that ships it, its origin, which ImportOrigins.Of in Core decides so the engine’s Start Game picker groups the same way: the originGame the importer stamped, else the content-source game its pallet id sits under (com.valvesoftware.hl2.* is Half-Life 2, resolved through PlatformRegistry), else the game it names. When game differs from the origin, the map was imported through another game (Half-Life 2’s d1_canals_01 through Garry’s Mod, which mounts it), and its row and page say so quietly as via Garry's Mod. A listing adds only names no imported map of that importer already holds, whichever game holds it.

Which game’s files a re-import reads is separate from where the map is listed: Half-Life 2 itself when it is installed, else an installed game that mounts it (ImportGames.MountFor: Garry’s Mod). The job and the row stay under Half-Life 2, the command runs --game com.facepunch.gmod, and Half-Life 2’s header and page say plainly that it is read through Garry’s Mod. A listing needs the game’s own install, so it is offered only there. The imported time is when the pallet’s source last changed, which is when an import last wrote it.

A map’s page says what each step does and lists only the ones that are needed, in the order to do them: Re-import (or Import) reads the game’s files again and rewrites the source, Build turns the source into the pallet games load. A map made by an older importer offers just Re-import (an import builds what it wrote, see below), a map whose source changed offers just Build, a map that needs both numbers them 1 and 2 with the first filled, and a map that needs neither offers both quietly.

Import and Re-import run dh import source-map or source2-map as a child process with --progress --plain. Studio finds dh the way it finds the engine host: beside the app, then in the checkout the workspace’s enginePath sits in (the newest build of Toolchain/DigitalHeaven.CLI), then on the PATH, and says every place it looked when there is none. The process’s progress lines (ImportProgress) move the bar and the step line (Step 6 of 18 · props 9 of 24, or write 4 so far for a count with no total). The bar moves within a step by its count and never goes back when a step’s next phase counts from zero again; a step with no total holds the bar at the step’s start. The item the step is on ("models/props_c17/oildrum001.mdl") shows faint under the bar, its path shortened in the middle so the file’s own name always shows, on the page and on the job’s Home card. summary lines (ImportSummary) become rows of the job’s timeline (below). Every other line is the raw log, kept behind a Log toggle under the steps. One job runs at a time; an import asked for while another job runs waits in the queue. Cancel kills the process and everything it started (ChildTool takes a cancellation token) and says that what it had written is still in the workspace. A dh that does not know --progress is named as older than Studio. A failed run says why in plain words (ImportFailure): the .NET exception’s own cause, not the last stack frame, with the raw text in the log under it.

A game’s page shows every map the game has, the imported ones and the ones a listing found, in the item browser the engine’s map and server pickers are drawn with (ItemBrowser in Authoring.Ui). There is no cap: the browser is virtual, so only the rows on screen are built. A size slider moves it between a list (the picture beside the name, the pallet and the state badge) and three sizes of tile, and remembers the size in settings.json (ImportMapSize). The tiles share the width a line leaves over, so a line ends at the pane’s edge. Each map is tinted by where it stands with the same card tints and badges as the Import list and Home (imported, stale, not imported, failed, importing, queued); there are no side stripes. An imported map shows its pallet’s maps/<name>.thumbnail.png, the others the asset plate every asset list uses.

A click picks one map, Ctrl adds or removes one, Shift picks the run from the last map picked (the host samples the modifiers as the pointer goes down, since a press carries none). The picks feed an Import N chip, which queues one job per map (StartBatch). A double-click opens the map’s page.

The map name field above it is a Halcyon AutocompleteField: as it is typed into, the game’s map names that start with, then contain, the text float in a list under it, each with its state badge; Up and Down walk them, Enter takes the highlighted one, Escape or a click elsewhere closes the list, and a click takes a row. The text also narrows the browser, and Enter with nothing highlighted imports the name as typed.

A map’s picture is addressed to its source, the pallet id and maps/<name>.thumbnail.png, not to what is on disk at that moment. SourcePictureSource keeps the last picture loaded for an address while a job rewrites or briefly removes the file and swaps in a newer file once it decodes (StickyPictures), so a map that is re-importing keeps its picture in the browser, on Home and in a group card’s member rows, and the running row keeps the same shape as its siblings.

Counter-Strike 2’s listing (dh import source2-map --list) names where each map comes from. A line is <name> <size> MB stock <pack> or <name> <size> MB workshop <id> "<title>" <pack> (a title escapes \ and " with a backslash), and ImportCommands.ParseMapList reads both into ListedMap (name, source, Workshop id, title); a line from a dh that prints no source is a stock map, and the summary line (72 maps in Counter-Strike 2: 48 stock, 24 from the Workshop.) and warnings are no map. A Workshop map is imported by its item id (dh import source2-map <id> --game cs2 --out ...), because a Workshop remake shares its name with a stock map and a name resolves to the stock one. So an entry’s Name, which keys the list, the picks, the queue and the job, is the id for a Workshop map and the map’s name for any other; what a person reads is ImportEntry.Label, the Workshop title, with the id and the map’s own name as the dimmed second line (3098765 · site48) on the rows, the tiles and the suggestions, and a title is also what the Filter and the map name field match, beside the id and the map name.

An imported pallet is matched to its entry by its id, com.steamcommunity.workshop.<id>, and grouped by ImportOrigins.SourceOf (the pallet’s metadata.source: stock, workshop or local), never by id prefix; ImportedMap.EntryName is what a re-import passes, so a stale Workshop pallet on Home is imported again by id as well. A Workshop pallet is named by its item’s title, so the picture is read from the thumbnail file the pallet holds under maps/ (ImportedMap.MapFile), not from maps/<title>.

A game with any map that is not stock gets its maps in groups, Stock, Workshop and Local, in the map browser and in the list on the left. The browser takes the groups from the shared ItemBrowser: a row’s Group makes the list and the tiles draw a heading, with the group’s count, ahead of each run of rows, and tiles wrap inside their group. The list on the left draws the same heading (ItemBrowser.Heading). A game with only stock maps shows none.

dh writes UTF-8 when its output is redirected, and ChildTool reads a child’s output as UTF-8 whatever the console code page is, so a title such as SCP Site48 [Демонстрация] reaches Studio intact. The font atlas bakes the Cyrillic block (U+0400 to U+04FF) after ASCII and its symbols, in both cuts at the text sizes up to FontAtlas.CyrillicMaxRung; a larger size draws from that rung, scaled. It is a separate pack pass, so every other glyph sits where it did and a frame with no Cyrillic in it is unchanged.

s&box is an Import game beside Counter-Strike 2 (ImportGames, Steam app 590830), imported by the same Source 2 module. Its listing (dh import source2-map --game sbox --list) tags a package’s map package <org.ident|unplaced> "<title>", the title escaped like a Workshop one, and prints a map’s revisions under it when it has two or more, indented: revision <crc> (<yyyy-MM-dd>)[ newest]. ParseMapList gives each ListedMap its Package ident (null for unplaced) and its Revisions. A package is imported by its ident (facepunch.construct), an unplaced one by its map name; both are an entry’s Name, so the list, the picks, the queue and the job key on it as they do for a Workshop id. Packages are the Packages group (ImportSources.Package), after Stock and Workshop, labeled by title with the ident and the map name dimmed beneath.

An imported pallet is read from metadata.package (the ident, or unplaced) and metadata.packageRevision, and a com.facepunch.sbox.* pallet belongs to s&box (the platform is a content source). The pallet is com.facepunch.sbox.<org>.<ident> for a known package (ImportOrigins.PackagePalletId) and com.facepunch.sbox.maps.<name> for an unknown one, which a listing line package unplaced is matched to by that map name. The import passes the full CRC from the listing as --revision, which also takes a prefix or a date. The engine’s map picker names the same group Packages, in the same place. The map’s picture is the thumbnail s&box downloaded, <install>\download\assets\<org>_<ident>\thumb.<crc>.png, the newest by modification time (PackageThumbnails); only some packages have one. It is asked for through the shared picture source by its file path (SourceFiles.ImageOf takes a full path as well as a pallet barcode), under the pallet’s own thumbnail when there is one, so a not-imported tile shows it too. The install folder comes from the machine read (ImportToolStatus.InstallRoots).

A map with two or more revisions offers a choice of them on its page and, while it is the one map picked, on the game’s page: ChoiceField in its dropdown shape, defaulting to the newest. Picking an older one is remembered per map (StudioShell.PickRevision) and an import of it passes --revision <crc>; picking the newest forgets the choice, since dh imports the newest without a flag. Where the pallet’s packageRevision differs from the revision the listing marks newest, the entry is ImportState.UpdateAvailable: an Update available badge in the warning tint (the platform list’s update badge and tint, no new card state), and an Update action that forgets any older choice and imports the newest. Home lists the same as a QueueKind.UpdatePackage item under the game’s icon, found only once the game’s maps have been listed, since the newest revision is the listing’s to say. A map the listing printed one revision of cannot be told to be out of date.

An import is one action: dh import …, then the build of what it wrote, with one bar and one line (ImportJob: the import steps take the first ImportShare of the bar, then Building <pallet> · Packing assets · maps/x.dh-map). The pallets built are the ones the import changed that need a build: new ones, and ones whose source was written since the job started (their SourceEdited compared with the workspace as it was when the job started). If it changed nothing, nothing is built. A separate Build stays for “the source changed, build it again”.

A job’s step list is a timeline whose rows add up to the elapsed time (JobTimeline, drawn by TimelineParts). It runs end to end from the moment the job was asked for to now, so the durations always sum to ImportJob.Elapsed: Queued (the wait for the jobs before it, Work.Waited), Starting dh (until the first progress or summary line), one row per import step (the importer’s sentence, with the duration Studio measured between summaries, so the gaps between steps are never lost), Preparing the build (the import’s last step to the build starting), Starting the build (until the first build report), then one row per pallet the build compiled, in the order dh build --progress began them. A pallet the job did not ask for is marked dependency of de_nuke (com.valvesoftware.cs2 is the usual one, and most of a CS2 re-import’s time). A finished pallet row says how many stages it reported; the running one shows its live stage and ticks. A gap row shorter than a second is folded into its neighbor rather than listed, and the sum still holds. A failed or canceled job marks its last row. Under the bar sit the elapsed time beside it and Import x of y steps · Build pallet i of n, where n is the requested pallets and every pallet of the workspace they depend on (BuildSteps.PlannedCount). The same rows serve the Import page (every row, then the total), the queue’s cards (the running row over the total, TimelineParts.Compact) and the kept failure log (a Timeline section). The build stream carries no per-pallet file counts, so a running pallet shows its stage and the file the compiler is on, not “312 of 958”.

Minecraft’s worlds are the items of its page (ImportView.Minecraft.cs, StudioShell.Minecraft.cs). The Import list keeps Minecraft’s own Client jar row (the content pallet) and lists the worlds imported; the game’s page has three sections:

  • INSTALLATION is a dropdown of the installations dh import minecraft-world --list --json found, grouped by launcher (Prism · 26.2 · Fabric · 8 worlds), and Add launcher folder, which asks for a folder and lists again with it as a --root. The chosen installation and the extra roots are written into the workspace’s dh-config.jsonc as "minecraft": { "instance": "<game folder>", "extraRoots": [ ... ] } (MinecraftConfig, which keeps every other key and never replaces a file it cannot read as JSON); dh reads the same block and its flags win. A notice says when no client jar of the installation’s version was found, and the remedy.
  • CONTENT is the content pallet’s Steps (Import, Re-import, Build) with a Version dropdown of the client jars (dh import minecraft --list --json, the newest release chosen) feeding --version. The content pallet’s importer version is read like any map’s, so it can read Stale; a notice with Re-import at 26.2 appears when a world in the browser was saved by a later Minecraft than the content’s platformVersion.
  • WORLDS is the shared item browser (tiles or rows, the same size slider), listed on its own when the page opens and by Rescan, with the same autocomplete field filtering by the name or the folder, an All installs toggle (off, the chosen installation’s worlds; on, every installation’s under a heading each), and Import N through the job queue. A row shows the save’s icon.png by path (a plate saying WORLD without one), the name, the folder dimmed, 26.2 · played 3 d ago · Survival (or Hardcore) and a state.

A world is one entry, named <instance key>/<folder> (the key is a short hash of the game folder), and its pallet is com.mojang.minecraft.maps.<slug>. A pallet holds a listed save when the folder matches and the stamped sourcePath or the seed does (WorldNames.Same), and each pallet holds at most one. The slug is the folder in kebab case; where another save holds that name, the instance’s name is added, then the seed’s last four digits, then a count; a folder with nothing a kebab keeps (CJK) is world-<hash>; an import of a world that already has a pallet writes into it. The states:

StateBadgeMeaning
Not importedNot importedNo pallet holds it
ImportedImported, whenA current importer made the pallet and it was built
Needs buildNeeds buildThe pallet’s source is newer than its build
StaleStaleAn older importer made the pallet
Played since importPlayed since import (warning)The listing’s lastPlayed is later than the stamped one
Open in the gameOpen in the game (warning)session.lock is held; the page’s notice has Import anyway (--force)
UnsupportedUnsupported (neutral, greyed)Not Anvil, or a data version before 1519 (Minecraft 1.13); shown with the reason, never hidden, and never imported
Source goneSource gone (neutral)The pallet’s sourcePath is no longer there and no listing finds the save; re-import is off, with the reason
Importing, Queued, Failedas for maps

A world’s page is the map page: the header (Prism · 26.2 · data version 4900), the notices, the import Steps, then WORLD with the dimensions choice (Overworld only or All dimensions, --all-dimensions), the map line (the map is kept when the world is imported again; Rewrite map is --rewrite-map), the seed and the save’s folder with Open, and the PALLET facts. An import runs dh import minecraft-world <save> --out <Source folder> --name <slug> --progress --plain and then builds the pallet it wrote. “World” is the word everywhere; a Minecraft save is never a map in Studio’s text. All the contract’s field names are read in one file (MinecraftWorlds.cs), so a change of them is an edit of that file.

dh imports, so what a map means is whatever the Compiler and Core beside it know. A build in Studio’s own process knows only the Compiler and Core bundled into Studio. A re-import that wrote fields the bundled Compiler had not met (dh.mover axis and angle) failed to build with “unknown field(s)” until the two matched. Toolchain is the one resolver: it finds dh (ImportToolLocator), compares the Compiler and Core beside it with Studio’s own by module version id, and decides.

  • Same build: builds run in Studio’s process (CompilerService), as Sources editing and auto build do.
  • Not the same build, or one that cannot be read: every build, an import’s included, runs dh build <pallet>… --progress --plain through that same dh (ChildBuild), as one build so the engine’s build marker covers all of it. dh prints buildprogress, buildcompiled, buildfailed and buildsummary lines (BuildReportLines in the Compiler) and Studio reads them into the same BuildOutcome. In-process validation is skipped in this case, with a note in the diagnostics, because an older Compiler would reject what the newer importer wrote.
  • When the two differ, Studio says so once, in the status bar and the log: Studio is older than dh, so builds run in dh. (or newer, or different builds). The Import page’s Builds fact reads “In dh” or “In Studio”. Two builds of the same source revision still build through dh but say nothing: the launcher builds Studio and dh together in Release, so a launcher build usually reads as the same build; the two diverge only when one was built apart from the other.

A failed import or build keeps the job (ImportJob.FailedPhase, the full Problem one failure to a line, the raw log up to 400 lines) and is an ImportTrouble:

  • a toast (Build of gm_x failed: <first line>), and the status bar’s left text, with the count (ErrorCount, which counts a failed build once, as a failed build);
  • the map’s row tinted with the danger card tint (CardState.Danger) and a Build failed or Import failed badge, and a card on Home;
  • on the map’s page, the whole message on the same tint with Copy (the message and the raw log go to the clipboard) and Show log for the raw log.

A failed job’s log is kept on disk, so a past run can still be read after Studio closes: FailureLogs writes the problem, the step summaries and the whole log to logs/ beside settings.json, one file per failure named by time, kind and pallet or map, and keeps the newest 50. The failed card on Home, the job’s Queue entry and the map’s page offer Open log, which opens the file in the system’s editor.

When a build’s error points at a place, the place travels with it: the Compiler’s PalletFailure carries the diagnostic’s pallet:asset/path (or file) and line, dh build --progress prints them on the buildfailed line, and the in-process build passes them in BuildOutcome.Locations. The failed card, the Queue entry and the map’s page show the place under the error, selectable, with Open, which shows the file in the workspace in the system’s file manager.

A failed build of a map that is a stale import says so on its Home card, Built from an import made by importer v16; re-importing may fix this, and offers Re-import in place of Try again; the map’s separate Re-import card steps aside for it.

It clears when the import or the build works again (the job’s result is replaced, or every pallet it built has built since). The Source 2 importer is an optional module beside dh. Its DigitalHeaven libraries are compared with the ones beside dh by module version id (ModuleFreshness), and a module published from other code, which would crash the moment it loads a type dh no longer has, is shown as Module out of date with Update module (the same publish) and its imports are held back until it is updated; a run that crashes with that mismatch marks the module out of date the same way. When the module is missing the CS2 page says so with Install module, which runs dotnet publish of Platforms/DigitalHeaven.Platforms.Source2 into modules/source2 beside dh from the checkout, and when there is no checkout it lists where dh looked.

ImportTests drives all of this through the shell against a stand-in tool (the list states, stale detection, progress parsing, cancel, the missing module); ImportBuildTests covers the toolchain choice (stand-in libraries that report their versions), import then build in Studio and through dh, and a failed import or build as a toast, a row state and a page message; ImportEndToEndTests runs one real import through the real dh into a scratch workspace when DH_IMPORT_E2E names a Garry’s Mod map (gm_flatgrass imports and builds in under a minute). --capture <folder> --progress photographs a batch re-import mid-run on an in-memory workspace (the group card, the Import page’s Cancel and queued row, three stacked notification cards and 8x crops of the Cancel button at 0, 50 and 100% under both shape sheets); --capture <folder> --import photographs the running import-then-build and the failed build (import-13 to import-16), and Half-Life 2 read through Garry’s Mod (import-17); --capture <folder> --import-browser photographs the map browser in tiles and as a list, two maps picked and the map name’s suggestions open (import-19 to import-22), in a run of its own so the maps’ pictures leave the other frames their texture slots. ImportBrowserTests, StickyPicturesTests, FailureLogTests and BuildLocationTests cover the browser, the pictures, the kept logs and the error places.

  • The 220 ms grid-to-list transition from the mock-ups.
  • Panes can be resized, swapped, stacked and tabbed together; they cannot be torn out into a window.

Studio references nothing from Engine/*, so its pictures of avatars, objects and materials come from the engine host running as --previewService <dir> (see the preview service). PreviewSidecar finds the host the workspace’s enginePath names the first time a picture is asked for, PreviewBroker keeps the queue and the cache, and SourcePictureSource hands the picture to the shared panes as a thumbnail.

  • Still pictures, not a live view. A picture is a PNG the engine wrote, asked for at a power-of-two side between previewMinEdge and previewMaxEdge so tiles of nearby sizes share one. The shared-frame mapping and the orbiting preview the first plan described are not built: OpenPreview still answers nothing, and the request already carries yaw, elevation and zoom for when it is.
  • A cache named by what is in the picture. The file name is PreviewProtocol.Fingerprint: the engine install, every built pallet’s path, size and write time, and every number of the request. A warm directory (previews beside settings.json) is answered without starting the engine, a rebuild or a new engine changes every name, and the directory is trimmed to previewCacheLimit pictures when Studio opens.
  • Newest first, cancellable. Requests run one at a time, the most recently asked first, so what is on screen is drawn before what scrolled past. A queued request can be cancelled; one already with the engine finishes and lands in the cache.
  • Failing softly. A source pallet that was never built asks for nothing. An engine that refuses an asset leaves that asset’s kind icon and is not asked again. An engine that dies is started again and the request tried once more; a request that kills it twice is given up on, and after previewMaxRestarts deaths previews are off for the session. With no engine at all the panes draw their kind icons, as they do while a picture is still being drawn.
  • Tests. PreviewTests drives the broker and the sidecar against a fake engine; PreviewEndToEndTests starts the built host and reads a real material back.

One look, one setting. The chrome (the top bar, the rail, the status bar) is a soft near-black, and the panes stand apart on it with a gutter between them (paneGutter), each a raised plate. A pane’s tabs are folder tabs: the front tab takes the pane’s own fill and one hairline runs up and around the tab and down and around the whole body, with no line between them; a tab’s corners are its pane’s. The rail behind the tabs is a tint between the chrome and the pane with its own fainter hairline, and past the last tab it carries faint diagonal lines (railPatternSpacing and railPatternAlpha in settings.json, 6 pixels apart at 0.2 of the border color). The tab look is a dressTabs hook on the shared DockChrome.Pane, so the editor’s own tabs are unchanged when none is passed.

Every corner radius comes from one design sheet (ShapeSheet): button 6, control 4, row and input 6, card 10, pane 8, badge 5, menu 6, progress bar ends 3 (square on Square). Settings → Appearance → Shape switches it to Square (nothing above 2) and back, applies at once and is written as shape in settings.json; Rounded is the default. Badges are filled. A settings file that still names a setting this build no longer has (the chrome, the pane frame, the tab style, the queue’s progress look and the rest of the retired picks) is read as it is and the keys are ignored; a key a file lacks keeps its default.