Skip to content

Source (export)

DigitalHeaven.Platforms.Source turns a DigitalHeaven avatar into a native Source engine playermodel. The result is an ordinary model: Valve’s player animations, ragdoll, jigglebones and shadows all work because nothing about it is special once compiled. Garry’s Mod uses it through its DigitalHeaven addon, and the CLI runs the same exporter as dh export source.

The library references only Core and Content, so it links into dh.exe and into the Garry’s Mod helper, which is published with NativeAOT. It also reads Source’s own formats, for importing a game’s maps and materials: see Reading Source Content.

An export runs in seven steps, and every host reports them by these names:

  1. Read the avatar. The avatar is loaded from the workspace’s compiled pallets as its resolved object, every component applied, together with the pallets it depends on.
  2. Fit to Valve’s skeleton. The avatar’s humanoid rig is mapped onto ValveBiped, and the SMD, QC and materials are written.
  3. Compile the model. Garry’s Mod’s own Windows studiomdl.exe compiles them in a scratch game folder, so nothing is written into the game install and none of the player’s addons are mounted. On Linux it runs under Wine (see Linux).
  4. Compile the hands. The viewmodel hands are compiled the same way.
  5. Compile the NPC. The NPC model is compiled the same way.
  6. Pack the .gma. The models and their materials are packed into one addon file.
  7. Done.

The hands and the NPC model are separate compiles of the same sources, so they run beside the playermodel’s, and their steps wait for them: Mayu exports in about 22 seconds either way.

The output is <hash>.gma, holding models/digitalheaven/<hash>.mdl (with its .vvd, .dx90.vtx and .phy), the hands in models/digitalheaven/<hash>_arms.mdl, the NPC model in models/digitalheaven/<hash>_npc.mdl, and the materials, flat, in materials/models/digitalheaven/<hash>/. The hash is a 16-character lowercase hex content hash of the avatar, the settings, every pallet the avatar reads, and the exporter’s own build. Source cannot replace a model that is already loaded, so a changed avatar gets a new path instead of overwriting the old one, and an unchanged avatar is never compiled twice.

The default recipe keeps the avatar’s own shape under Valve’s animations:

  • Scale. The avatar is scaled uniformly so its hips land at ValveBiped’s hip height, read from the game’s own m_anm.mdl. The scale-up is capped at 1.3×, so a small avatar grows toward a stock player’s size but stays small. A tall avatar is scaled down freely.
  • Own proportions. The model declares the avatar’s own bone positions, and an always-on autoplay layer (proportions) restores them under Valve’s animations, which would otherwise stretch every limb to ValveBiped’s lengths.
  • Rest angles. The pelvis, spine, neck and head keep ValveBiped’s rest angles exactly. Limb segments are swung to aim along the avatar’s own limbs. Hands, feet and toes inherit their parent’s angle.
  • Foot IK. The model declares Valve’s four IK chains and locks the feet during autoplay.
  • Axes. Forward is −Y, left is +X, Z is up, and one meter is 39.37 inches.
  • Weights. Each vertex keeps its three strongest bones, renormalized. Vertices at the same position (seam copies) share one weight set, so a seam cannot open.
  • Bones. A model is capped at 128 bones. Past that, the deepest bones that are not humanoid, and not part of a spring chain, are merged into their parents. The rig’s LeftEye and RightEye are always kept, even with no mesh on them (an eye mesh usually hangs on a child), since the eye look turns them.
  • Eyes. The playermodel and the NPC model carry an eyes attachment on the head bone, at the avatar’s real eye center (between its eye bones, else the head bone at eye height), facing the way the model faces as a stock one’s does. Anything reading “eyes” (spawn icons, NPC eye contact, first-person cameras) finds the avatar’s eyes. Without it, the eyes of the included player animations applied: a human’s, which on a big head sits at the chin.
  • Ragdoll. One body per stock ragdoll bone (15, as a stock citizen has), whose hull is the convex hull of the vertices that mostly ride it, less the outermost 2% on each axis so a stray strand of hair or an earring doesn’t stretch it (sampled along 96 directions, so its corner count is bounded). Spring chains get no body. Each body gets a share of the 90 kg from Winter’s body segment masses, written as $jointmassbias, so a big head or a fluffy tail never makes a body heavy; steep mass ratios along a chain are what make a ragdoll’s limbs jitter. Only the pairs that fold into each other collide (forearms and hands with the torso and thighs, the legs with each other, as $jointcollide), and a pair whose hulls overlap at rest, or with the arms hanging straight down, is left out: hulls a joint presses into each other push apart every tick, and a wide avatar’s hanging arms sit inside its hips. Each joint gets a range from named defaults: flexion and extension, spread, and twist about the bone’s length. The fit keeps ValveBiped’s bone axes, so each range lands on whichever of the bone’s own axes bends it that way, with the sign that does; knees and elbows are hinges that fold one way only. The axis and sign rule was checked against stock ragdolls, whose knee folds on +z, hip and elbow swing forward on −z, and left and right hips spread on −y and +y.

Each dh.material becomes a VertexLitGeneric VMT (UnlitGeneric for the unlit shader):

DigitalHeavenSource
baseColor texture$basetexture: DXT1, or DXT5 when it has alpha; point-sampled textures stay uncompressed and unfiltered
normal texture$bumpmap, with the green channel flipped (DigitalHeaven’s normal maps are +Y up, Source’s are −Y)
roughnessPhong: $phongboost and $phongexponent grow as roughness falls. An unauthored roughness is 1, as in DigitalHeaven
tint$color2 (and $alpha for blend)
alphaMode mask / blend$alphatest with the cutoff / $translucent
renderFace both$nocull

Textures are written as VTF 7.2 by the exporter’s own writer, at most 1024 pixels on a side.

Each dh.springBone chain becomes flexible $jigglebones on the segments DigitalHeaven itself swings: every bone it aims at a child, the anchor included, so an ear or a tail swings from its root. A tip with no endpointPosition only carries its parent’s rotation, as it does in DigitalHeaven; with one, the tip swings over the endpoint’s length. A zero-length twin that an armature link grafted in is skipped for the next child that has a direction.

The translation reads the chain’s resolved rates, so a jiggle chain and a physBones chain that move alike in DigitalHeaven move alike here. Source’s jigglebone is a spring and damper on a virtual tip, stepped once per frame on the frame time; DigitalHeaven keeps a share of each bone’s velocity and then pulls it toward rest, and that pull takes energy out too. The export writes the spring and damper whose step at 60 Hz matches DigitalHeaven’s own:

DigitalHeaven$jigglebone
pull and velocity lost, per frame at 60 Hzthe stiffness and damping whose roots, stepped at 60 Hz, are DigitalHeaven’s: damping = −60 · ln((1 − pull)(1 − lost)). Both are capped so the game’s integrator stays stable at jiggleStableFrameRate
gravity (and gravityFalloff)tip mass = the gravity in inches/s² at the fit’s scale. Source springs each bone relative to its parent’s swing, so each tip carries only what its depth adds to its parent’s share: with no falloff, the chain’s first swinging bone carries it all
limitType angle / hinge / polarpitch_constraint and yaw_constraint of the limit’s width / pitch with yaw locked / pitch and yaw
no limitpitch_constraint and yaw_constraint of jiggleSafetyStop

A pitch or yaw stop zeroes the tip’s velocity where it lands, like DigitalHeaven’s inelastic cone. angle_constraint is never written: it keeps the velocity, so a tip that reaches it slides round the rim, and a chain of them keeps circling long after the player stops. Friction is not written either: Source’s jigglebone code does not read it.

Garry’s Mod draws first-person hands as a separate model, bone-merged onto every c_ weapon: each bone named like one in c_arms_animations.mdl lands where the weapon puts it. The hands are cut out of the exported playermodel:

  • Bones. The playermodel’s own skeleton from the pelvis up to Spine4, the clavicles and both arms. The names, frames and rest lengths are unchanged, so the avatar’s own hands and forearms ride the weapon’s arm bones, and the joints absorb the difference in length.
  • Mesh. Every triangle whose vertices each carry at least half their weight on an arm (an upper arm and everything below it). That keeps the sleeves and the whole upper arm and cuts at the skinning’s own shoulder boundary. Weight on a bone the weapon cannot move (the spine below Spine4) moves to the nearest bone it does move.
  • Model. models/digitalheaven/<hash>_arms.mdl, in the same .gma, using the playermodel’s materials and spring bones. It includes weapons/c_arms_animations.mdl, the way stock hands do. It has one skin and one bodypart, so it takes skin 0 and bodygroups "0".

A failure here never fails the export: the result carries hands-no-mesh or hands-failed, and the game keeps its stock hands.

A playermodel includes only the player animations (m_anm.mdl), and an NPC asks for activities that set doesn’t have. So the export also compiles models/digitalheaven/<hash>_npc.mdl: the same QC, mesh, bones, proportions layer, foot IK and ragdoll, on exactly a stock male citizen’s animation sets (humans/male_shared, male_ss, male_gestures, male_postures). Their rest pose matches m_anm.mdl’s within 0.06°, so the fit holds unchanged.

  • A separate model, not more includes. The citizen sets share one activity (ACT_LAND) and a few sequence names with the player set, and the combine set shares 36 activities with the citizen’s; an entity picks among every sequence of an activity, so mixing them would play the wrong set’s animations at random. Workshop packs do both; the well-made ones keep the sets apart.
  • Citizen animations only. Garry’s Mod’s own spawn list makes a hostile NPC out of npc_citizen with the Hostile keyvalue, so both the Friendly and the Hostile NPC use this one model.

A failure here never fails the export: the result carries npc-failed, and the avatar has no NPCs.

The avatar’s eyes follow its rig and its dh.eyes, the same resolved behavior every other host reads:

  • Eyelid shapes become flexes. Each blend shape the rig’s eyelids names (the blink shapes, lookUp, lookDown) is written to a VTA as a flex, at its full weight over whatever the rest pose already bakes in. Only the shapes the avatar’s dh.eyes lets run are written: with blink.enabled: false there is no blink at all, and with look.enabled: false no lid follow. Each is declared decay 0, so every vertex follows its controller at once. By default studiomdl lets a vertex that moves less than the flex’s largest take part of the renderer’s lagged copy of the weight, and a quarter-second blink then only brought the lids halfway down.
  • Source’s own blink. Each blink shape is driven by blink * (1 - dh_native_off) + dh_blink_<i>. blink is the controller Source’s own blink drives, every 1.5 to 4.5 seconds, for every player and NPC, so an avatar blinks in every game without the addon’s module. A client running DigitalHeaven’s own eye brain raises dh_native_off and drives dh_blink_<i>, dh_look_up and dh_look_down itself.
  • Eye bones. The rig’s eye bones are kept, and the limit poses of its eyeRotationLimits are converted into the exported bones’ own frames, so a turn composed over an exported eye’s rest is the turn the avatar was authored with.

Both the playermodel and the NPC model carry all of it. Source’s $eyeball is not used: it draws an iris projected onto an eye mesh and never turns a bone, and a DigitalHeaven avatar’s eyes are meshes on bones.

A bullet’s blood splat on a model is a decal the game builds by copying every triangle, facing the shot, that a square about 16 inches wide touches, straight through the model and on every layer it passes. The game refuses a splat of more than 16384 indices (“Decal has more than 16384 indices! Not adding”), and a model that keeps making such splats now and then draws one frame of blood sheets and streaks across the screen. Stock models never come near the limit (a head shot on male_07 is about 3,400 indices). A DH avatar’s head is ten times denser: an unprepared pitr Mayu made 41k–48k-index splats, and so do Workshop Mayus.

So the export measures the model’s splats, sweeping that square over the compiled geometry from 24 directions and clipping each triangle to it as the game does, and decides, mesh by mesh, how the model takes blood:

ChoiceWhat the mesh gets
onIt takes blood itself, as a stock model does
offIts materials become $nodecal copies: no blood lands on it
proxyWhere its splats are too big, $nodecal materials plus a decimated copy of just that region that is never drawn ($no_draw). The blood lands on the copy, so it shows on the mesh with a fraction of the triangles. The rest of the mesh keeps its own blood

Unless a mesh is named in the settings, the export picks:

  • off when its largest connected piece is at most bloodTinyMesh of the model’s height: lenses, piercings, whiskers, a collar’s bell. Its see-through materials (blended, cut out, or glass) take no blood on any mesh.
  • proxy for the triangles inside any splat over bloodSplatBudget. Each mesh’s region gets one copy, decimated by quadric edge collapse onto the mesh’s own vertices. The copy keeps the region’s border and sits exactly on the surface (studiorender’s decal depth offset keeps the blood visible without a lift). It refuses collapses that would leave slivers, which stretch a splat’s texture. Faces toward the front, where most shots land, keep more of their shape. The copy is decimated until its own worst splat is at most bloodProxySplat, and further, in proportion with the other copies, while the model’s worst splat still passes the budget.
  • on otherwise.

The measure takes the rest pose and lands within a few percent of the game there. A fighting NPC holds its arms across its body and stacks more layers into one splat, so the default budget keeps three eighths of the limit spare. Every export logs each mesh’s choice, why, how many triangles its copy stands in for, and the worst splat before and after.

These settings shape an export:

KeyValues (default first)Meaning
proportionsown, hl2hl2 leaves out the proportions layer, so Valve’s animations stretch the avatar to a stock player’s lengths
fithips, eyeseyes scales the avatar so its eyes sit at a stock player’s 64 inches, uncapped; the addon then scales the pelvis and plants the feet in game
springBonestrue, falseWhether spring chains become jigglebones
handstrue, falseWhether the export also makes the viewmodel hands
npcstrue, falseWhether the export also makes the NPC model
bloodan object of mesh name to on, off or proxyOverrides the automatic blood choice for the meshes it names. A mesh is named by its node path (Collar/Armature/CatCollar_Bell, as the export log prints it) or the path’s last segment (CatCollar_Bell). Each layer’s names merge over the lower layer’s
bloodSplatBudget10240The largest splat, in indices, a model may keep before a region gets a proxy
bloodProxySplat10240The largest splat a proxy may make on its own: its first decimation target
bloodTinyMesh0.1A mesh whose largest piece is at most this share of the model’s height takes no blood
jiggleStableFrameRate20The lowest frame rate, in frames per second, the spring bones must stay stable at; their stiffness and damping are capped for it. Source stops simulating jigglebones below 20 (cl_jiggle_bone_framerate_cutoff)
jiggleSafetyStop80The pitch and yaw stop, in degrees, a spring chain with no limit gets, so a bone never swings behind its parent

Settings are layered. Each layer states only what it changes, and a higher layer wins:

  1. The built-in default.
  2. The workspace, in dh-config.jsonc.
  3. The avatar’s own pallet.
  4. The user’s force: the addon’s settings, or dh export source flags.
dh-config.jsonc
{
"platforms": {
"gmod": { "fit": "eyes" }
}
}

A source block beside it holds settings for every Source game, and the gmod block layers over it (see Platform IDs). Other DigitalHeaven tools keep the platforms block when they save the file.

An avatar’s pallet can carry its own layer: a JSONC file at the avatar’s path under platforms/gmod/ (or under the ID com.facepunch.gmod), with .jsonc in place of .dh-avatar. A file at the same path under platforms/source/ (ID com.valvesoftware.source) holds settings for every Source game and is read first, so the game’s file overrides it. Like every platform override, it is packed into the pallet as a plain file.

platforms/gmod/my-avatar.jsonc (for my-avatar.dh-avatar)
{
"fit": "eyes",
"springBones": false,
// Per mesh: blood on the collar itself, none on the snout whiskers, the body through a proxy.
"blood": {
"Collar/Armature/CatCollar": "on",
"SnoutWhiskers": "off",
"Body": "proxy"
}
}

An avatar at avatars/stick-figure/stick-figure.dh-avatar reads platforms/gmod/avatars/stick-figure/stick-figure.jsonc. An unknown key or value is skipped with a warning; it never fails an export.

The compiled model’s $keyvalues hold a digitalheaven block the addon reads: barcode, proportions, fit, springBones, scale, hipRatio (the pelvis height over ValveBiped’s) and ankle (the foot bones’ rest height, in inches). The same hipRatio and ankle come back on every export result.

An avatar whose eyes move or blink also carries its eye rig there, as Core’s EyeRig writes it:

KeyHolds
eyesThe avatar’s resolved eye behavior, as the JSON of the dh.eyes that states it, in base64
eyeLimitsUp, down, left, right, inner and outer, in degrees
eyeLeft, eyeRightThe exported eye bones
eyePoseLeft, eyePoseRightEach eye’s up, down, left and right limit poses in its exported bone’s frame, as four quaternions (x y z w); absent when the eyes don’t look around
eyeBlink, eyeBlinkEyesThe flex controller of each blink shape in order (- for one that didn’t resolve), and the eye it closes
eyeBlinkOffThe controller that turns Source’s own blink off (dh_native_off)
eyeLookUp, eyeLookDownThe lid follow controllers
Terminal window
dh export source <barcode> [--out <dir>] [--game <garrysmod dir>] [--proportions own|hl2] [--fit hips|eyes] [--spring-bones on|off] [--hands on|off] [--npcs on|off] [--rebuild] [--verbose]
dh export source
  • --game defaults to the Steam install of Garry’s Mod.
  • --out defaults to the game’s garrysmod/data/digitalheaven/avatars, the folder the addon mounts from.
  • With no barcode, it lists the workspace’s avatars with their content hashes and whether each is exported, outdated or not exported yet.

The add-on’s exporter (dh-gmod-export) is installed into the game by hand and is not rebuilt with the workspace. A pallet records the object shape it was compiled under, and when one is newer than the exporter reads, the export stops with addon-outdated and says to rebuild and reinstall the exporter (native\build.ps1, then native\install.ps1). Without that, an older exporter reads a newer pallet as an avatar with no humanoid rig. Pallets compiled before the record existed are read as before.

Every host finds the game and its compiler through one resolver, SourceGameInstall:

  • The game is the first steamapps/common/GarrysMod/garrysmod in a Steam library: the Steam root (Program Files (x86)/Steam on Windows; ~/.local/share/Steam, ~/.steam/steam or the Flatpak’s on Linux), then every library its libraryfolders.vdf lists.
  • studiomdl is DH_STUDIOMDL when set, then the game’s bin/win64/studiomdl.exe, then the 32-bit branch’s bin/studiomdl.exe.

It is never redistributed.

Garry’s Mod’s Linux install ships no model compiler, and Valve’s Linux tools have none either, so on Linux the exporter runs the game’s Windows studiomdl under Wine. Nothing else in the export is OS-specific.

  • studiomdl must be named with DH_STUDIOMDL, pointing at a studiomdl.exe beside the DLLs it loads: tier0, vstdlib, steam_api64, filesystem_stdio, materialsystem, shaderapiempty, studiorender, mdllib and vphysics (about 8.4 MB, all from the x86-64 branch’s bin/win64). Portal’s 32-bit studiomdl is not a substitute: it refuses $maxverts, stops at 128 bones, drops small bone weights and crashes on large meshes.
  • Wine is DH_WINE when set, else Proton from the game’s own Steam library or any other (the newest numbered version first, then Experimental), else wine on PATH. Proton’s own libraries go on LD_LIBRARY_PATH in place of the game’s. Proton 11.0 runs it with no winetricks.
  • The prefix is DigitalHeaven’s own, ~/.local/share/DigitalHeaven/studiomdl-wine (under $XDG_DATA_HOME when set; DH_WINEPREFIX overrides it), so a ~/.wine is never touched. The first export creates it with wineboot -u.
  • Headless. Wine runs with no DISPLAY or WAYLAND_DISPLAY, no Mono or Gecko prompts (WINEDLLOVERRIDES=mscoree,mshtml=), no debug output and none of the game’s LD_PRELOAD.
  • Paths reach it through Wine’s Z: drive, including the scratch gameinfo.txt’s search paths.
  • Stopping. Wine runs studiomdl as its own process, outside the tree the exporter started, so a canceled or timed-out compile also kills every process whose command line carries that compile’s QC path.
  • Same model. The same QC set compiled natively on Windows and under Proton 11 on SteamOS gave byte-identical vertices, meshes and physics; the .mdl differed only in 8 bytes of unwritten padding and the model checksum that covers them. A native compile is itself byte-for-byte repeatable.
  • Time. A whole Mayu export took 25 to 34 s on a Steam Machine under Proton. Its playermodel compile alone took 25 s there, against 19 s natively on the Windows dev PC. Wine’s own startup costs about 0.6 s per compile.

The compile is one seam (IModelCompiler) with one implementation today: studiomdl, run by an IToolHost that starts it directly on Windows or under Wine elsewhere. A native C# writer of the .mdl, .vvd, .vtx and .phy could replace both, with studiomdl kept as its test oracle.

The same library reads Source’s own formats, for importing a game’s maps, textures and materials: the game install is the ingredient and the importer is DigitalHeaven’s recipe. It reads everything in place, by offset: a BSP inside a Workshop .gma is never extracted, and no reader loads a whole archive. Every count and length read from a file is checked against the file’s size before anything is allocated for it. Nothing here converts to DigitalHeaven formats; the importer builds on these readers.

ReaderReads
BspFileCompiled maps, VBSP versions 19 to 22, with typed views of the lumps
PakfileReaderThe zip inside a BSP’s pakfile lump (stored and deflate)
KeyValuesReaderVMT, gameinfo.txt, mount.cfg, mountdepots.txt, libraryfolders.vdf, appmanifest_*.acf
VtfImageVTF 7.0 to 7.5: every frame, cube face and mip
GmaArchiveWorkshop addons (.gma), and legacy *_legacy.bin items
VpkReaderVPK v1 and v2 sets
SourceFileSystemAn ordered stack of folders, VPKs, GMAs and a map’s pakfile
SourceMapCatalogThe maps in a stack, the way Garry’s Mod’s menu lists them

BspFile.Open reads only the header. The 64 lumps are read when a view asks for them and cached.

  • Versions. 19, 20, 21 and 22. Version 21 has two directory layouts: L4D2’s {version, offset, length, ident} and the classic {offset, length, version, ident} that Workshop maps such as ttt_severed use. The reader tells them apart from the file, and UsesLeftFourDeadHeader says which it found.
  • Compressed lumps. TF2 stores lumps as Valve’s LZMA header around a raw LZMA stream. They are decoded on read by LzmaDecoder, a pure-managed decoder written from the public-domain LZMA SDK’s algorithm, which also reads the LZMA-alone files of legacy Workshop items. A lump’s fourCC counts as its decoded size only when its bytes start with the LZMA header. A compressed game sub-lump’s length is its decoded size; its stream length is in its header.
  • Views. Planes, vertices, edges, surface edges, faces (FACES and FACES_HDR), original faces, texinfo, texdata and its string table, models, brushes, brush sides, nodes, leaves, areas, leaf brushes and faces, displacements (info, vertices, triangle tags), cubemaps, overlays, world lights (88 or 100 bytes), and the entity lump parsed into an ordered list of key/value pairs with repeated keys kept.
  • Game lumps. The directory, the static props (sprp, versions 4 to 11, the entry size taken from the lump; version 10 keeps its flags in a 32-bit field at +64, earlier ones in the byte at +31) and the detail props (dprp).
  • Pakfile. OpenPakfile reads the embedded zip in place. A method other than stored and deflate is listed but refuses to open, naming the method number.
  • Partition tree. WorldTree and ModelTree hold the nodes and planes from a model’s head node, with Source’s child encoding (a negative child is the leaf -child - 1; a point is in the front child when dot(normal, p) - dist >= 0), the reachable node and leaf counts, the deepest path, and FindLeaf. A brush entity’s model has its own tree over the same leaves, and no node names leaf 0.

Lightmaps. Each face has up to four light styles. A face with n styles stores n average colors, in reverse order just before its lightofs, then for each style one block of (sizeS + 1) * (sizeT + 1) luxels, or four blocks when its texinfo has SURF_BUMPLIGHT (block 0 is flat, 1 to 3 the bump basis directions). BspLightmaps describes that layout, and the sum over lit faces is the lump’s length exactly on almost every map. A luxel decodes as byte * 2^exp / 255, and a leaf ambient sample as byte * 2^exp with no division; the two are checked against gm_flatgrass’s own light_environment.

ttt_severed (BSP v21) stores one more block per style, all zero in the faces checked, after the blocks above. BspFile.LightmapExtraBlocks finds it by trying 0, 1 and 2 until the faces’ blocks add up to the lump’s length, and returns null when none does; block 0 is still the flat lightmap. L4D2 maps have no LDR lighting at all: LIGHTING is empty and FACES_HDR addresses LIGHTING_HDR.

Leaf ambient lighting. Every sample is tagged with the index of the leaf that owns it, and each leaf has a box, cluster, area and contents. AmbientHdr, AmbientLdr and AmbientInline expose the three sources (lumps 51 and 55, lumps 52 and 56, and the cube inside each 56-byte leaf of BSP v19 and older, placed at the center of the leaf’s box). PreferredAmbient takes HDR unless it is only a stand-in: an LDR-only map (gm_PiterCity) carries one fallback sample per leaf in the HDR lumps, which IsFallbackOnly reports. A leaf with no samples of its own, every solid leaf and some open ones, stores {count 0, first} where first is the index of the nearest leaf that has samples, not a sample index. ResolvedRange and SamplesFor follow it, so own-leaf-plus-fallback is exact; a leaf that names itself has no light.

SourceMounts builds a SourceFileSystem the way the game does. A lookup lowercases the path, turns \ into / and drops a leading / or ./, so it behaves the same on Windows and Linux; each hit names the layer that served it.

  • Search paths. The game’s gameinfo.txt SearchPaths in order, honoring |gameinfo_path| and |all_source_engine_paths|, dir/* wildcards and .vpk sets. A folder also mounts every *_dir.vpk inside it. Entries that only name a binary folder are skipped.
  • Garry’s Mod. The search paths, cfg/mount.cfg, and each game cfg/mountdepots.txt enables that is installed in any Steam library. Its addons are not where gameinfo.txt puts them: they go to a tier of their own after everything else (below).
  • Owners. Each layer carries the pallet that owns its content, named for the maker of the bytes: com.facepunch.gmod, com.valvesoftware.hl2 for the hl2 folders and sourceengine/hl2_* packs, com.valvesoftware.cstrike for content_cstrike, and com.steamcommunity.workshop.<itemId> for a Workshop addon. A map’s own pakfile (SourceMounts.AddMapPakfile) mounts in front of everything and names no owner: the importer is told the map’s pallet. Games with no assigned id (INFRA, Zombie Panic! Source) have none yet.

While a map is imported the stack is searched in this order, first match wins:

  1. The map’s own pakfile.
  2. The map’s own Workshop item, when the map is a Workshop map (SourceFileSystem.Promote, for the import only).
  3. The game’s content: Garry’s Mod’s own files and every mounted game (Half-Life 2, its episodes, Counter-Strike: Source and so on).
  4. Content addons, fill only (SourceFileSystem.AddAddon): every installed Workshop item of steamapps/workshop/content/4000 (a legacy *_legacy.bin is read from garrysmod/cache/workshop when that copy exists, else decoded) and every loose addons/* folder. cfg/addonnomount.txt is ignored, so a disabled addon counts the same as an enabled one. Being last, an addon is only reached for a path nothing above holds. When two addons hold the same path, the lowest Workshop item id wins, then loose folders by name, so the result never depends on install order.

An import captures a map as it exists on the Workshop, which many people have, not one player’s setup. Which addons are enabled or disabled is that player’s setup, so the import does not read it. Addons only fill the paths the map needs and the game lacks, such as a map’s separate content packs, and can never replace a file the game or the map provides. Two people who import the same map therefore get the same result, and a shared project references the same assets for everyone. A replacement addon, such as a props replacement pack, never applies: the game’s own file is always found first.

SourceMapCatalog.Discover lists maps/*.bsp across the stack, once per name, with each map’s layer, size, BSP version, thumbnail (maps/thumb/<name>.png in the same layer, else in any layer, such as garrysmod_dir.vpk), New Game category and hidden flag. The category and hidden rules are GModMapCategories, the tables of Garry’s Mod’s getmaps.lua as data. A file that is not a readable BSP, such as a zero-byte stub, is left out unless asked for.

On the development PC the stack is 375 layers (42 folders, 56 VPK sets, 277 addons) and mounts in about two seconds; discovery finds 771 readable maps in 0.4 seconds. Counts depend on the install.

SourceMaterialImporter turns the materials a Source map names into DigitalHeaven materials, on demand. It is a library in Platforms.Source, so the map importer and Studio call the same code. Given a texdata name, the mount stack (with the map’s own pakfile mounted in front, SourceMounts.AddMapPakfile) and the options, it returns one of four things:

OutcomeMeaning
MaterialA barcode, and the CubemapOrigin when the name is a cubemap patch
DropFaceA tools material (nodraw, clip, trigger, skip, hint, invisible, …): the face is not drawn
SkyA sky material: the map importer handles the sky
UnresolvedNo VMT in any layer: the slot draws the missing-material checker

Every result carries its warnings, and Report counts what a run did. Flush() writes the pallets’ manifests and indexes once, after the last name.

The pallet that owns the layer serving a file owns the file’s material, named for the maker of the bytes (see Mount stack): com.facepunch.gmod, com.valvesoftware.hl2, com.valvesoftware.cstrike, com.steamcommunity.workshop.<itemId>. What the map’s own pakfile serves belongs to the map’s pallet, <game id>.maps.<mapname>, which the caller names. Base pallets only grow with the materials maps ask for; nothing decodes a whole game.

A material’s textures are looked up from its own layer and the layers searched after it, so dependencies only point down the stack. When a map packs a copy of a base texture, a base game’s material still takes the base game’s texture, and the base pallet never depends on a map or Workshop pallet. Only a texture that no such layer holds is taken from a layer above; when the material is a game’s and the layer above is an addon’s, the texture is written into the game’s own pallet instead, so a game pallet depends on game pallets alone. A model’s materials follow the same rule from the model’s layer, so a model a content addon supplies finds its materials in that addon first, as it was made; a game’s model whose material only an addon holds leaves that surface unbound (addonOnly warning).

Every pallet the importer writes into declares every pallet its material, object and map files point into, read from the files rather than from what the run touched, so a material an earlier import left behind or this run skipped as unchanged is declared too. A file that points a game’s pallet at an addon’s fails the import, naming the file, before anything is built.

A pallet the importer writes into drops a dependency once no file in it names the depended-on pallet any more, but only when that pallet is one the importer generated. A dependency declared on any other pallet is left alone.

FileBarcode or path
Material<pallet>:materials/<name>.dh-mat, the lowercased Valve path with forward slashes
Texturetextures/<vtf path>.png; a normal map is .normal.png
Derived texturetextures/<first VTF>.<hash>.<kind>.png, where the kind is selfillum, envmask, mr, flipbook (an animated texture’s atlas), solid (an additive texture whose alpha Source never reads, made solid) or premultiplied (an UnlitTwoTexture layer with its alpha multiplied into its color), in the pallet of the material that asked for it. <hash> is eight hex digits of a hash of everything the texture is made from (the VTFs’ bytes, the recipe and the texture version), so two materials with the same inputs share one file. A re-import deletes a derived texture that no material, object or map of the pallet names any more

The same source gives the same barcode on every machine. A texture in another pallet than its material (a gm_construct material over an HL2 texture) is a barcode into that pallet, and the material’s pallet declares it in dependencies; the map pallet declares every pallet a barcode points into. A missing declared dependency is a named warning in DH, so the importer never leaves one out. An existing manifest only gains dependencies. Pallets sit under the pallets root at <first two id segments>/<rest> (the rest stays one folder, so a map pallet is never inside the base pallet it depends on).

Each pallet keeps import.dh-import, an index of what each generated texture was made from (a hash of its source bytes, its recipe and the textures stage version). A second import decodes only what changed, and the compiler leaves the index out of the compiled pallet. A material file is rewritten only when its bytes differ. The same hash keys the workspace’s import cache, so a texture another map or an earlier import already decoded is written from there. The versions are Core’s ImporterStageVersions.SourceMap: Textures is bumped when a converted texture changes and Models when a decoded model does; Materials is the index’s own version, and a bump settles every texture and model again from the import cache, which it does not key, so no model is decoded again for it.

Textures are decoded on every core before the map’s materials are imported: Prepare converts every texture a texdata name’s material would bind, and a model’s skins’ materials, into memory, and the import that follows writes, records and reports them in its own order, so the files and the report are what a one-at-a-time import gives.

Source’s cubemap materials are patch VMTs in the map’s pakfile: an include plus replace or insert. They are resolved at import time, nested patches included. A patch whose only change is its $envmap is the material it includes: it shares that material’s barcode, and its origin is read from the name suffix _<x>_<y>_<z> (else from the $envmap’s c<x>_<y>_<z>) and handed to the map importer, which binds the faces to that cubemap’s probe (see Reflections). A patch that changes anything else is a material of the map’s pallet.

SourceDigitalHeaven
LightmappedGeneric, VertexLitGeneric, WorldVertexTransitionlit shader✅
UnlitGenericunlit shader✅
$basetexture, $color, $alphabaseColor, tint✅
$bumpmapnormal, green flipped (Source is -Y, DH +Y)✅
$translucent, $vertexalphaalphaMode: blend✅
$additivealphaMode: blend, blendMode: additive, the texture as it is (see Additive and multiplying surfaces)✅
The Modulate shader; with $mod2x; DecalModulateblendMode: multiply; mod2x; mod2x, unlit, the texture as it is✅
$decaldepthBias: 1✅
UnlitTwoTexture’s $texture2, $texture2transformThe detail layer (mul, mulx2 with $mod2x) at its own uvScale; when only the second texture scrolls, it is drawn as the base so its scroll plays, and the first is the detail layer🟡
SpriteCard and Sprite; an additive or modulate surface that fades by $vertexalpha or whose texture is a render targetInvisible: an unlit, fully transparent material, and a world face that uses it is left out. Named in the report’s hidden🟡
$alphatest, $alphatestreferencealphaMode: mask, alphaCutoff✅
None of thosealphaMode: opaque, written explicitly. The base texture’s alpha is a mask here ($basealphaenvmapmask, $selfillum, $blendtintbybasealpha, phong masks), never opacity✅
$nocullrenderFace: both✅
$selfillum, $selfillummask, $selfillumtintemissive (the base color through the mask), emissiveColor✅
$detail, $detailscale, $detailblendfactor, $detailtintdetail block (own uvScale, strength, tint)✅
WorldVertexTransition’s $basetexture2, $bumpmap2, $basetexturetransform2detail block blending baseColor by lerp, painted on by vertexWeight: "r": a displacement’s blend alpha, which the map’s geometry carries as COLOR_1 (see Blended displacements). It takes the place of a $detail, which is dropped with detailReplaced✅
$blendmodulatetexturenot carried: the layers cross-fade by the alpha alone (blendModulate)🟡
$detailblendmode 0 and 7, 1 and 5 and 6, 2 and 3, 8mulx2, add, lerp, mul; any other is mulx2🟡
Decal modulate ($detailblendmode 0)mulx2 over a remapped copy of the detail texture, textures/<detail>.mod2x<factor x 100>.png, with $detailblendfactor folded into the texels and left out of strength✅
$envmap, $envmaptint, $envmapmask, $basealphaenvmapmask, $normalmapalphaenvmapmask, $fresnelreflection, $envmapfresnel, $envmapcontrast, $envmapsaturation, $envmaplightscaleAn envmap block, written when $envmap is set and the tint is not black: tint, mask (texture, and from: texture, baseAlpha or normalAlpha), fresnel, contrast, saturation and lightScale (see Reflections). The fold a renderer without the block reads is kept as well: roughness, and metallic on a metal, with a mask becoming a metallicRoughness map. A base-alpha mask is read inverted, as LightmappedGeneric and VertexLitGeneric read it (specularFactor *= 1 - alpha): a surface reflects where its base alpha is low🟡
$surfacepropsurface.preset (Source’s names fold onto DH’s fourteen presets; concrete is stone)✅
$basetexturetransformuvScale from its scale🟡
Water ($fogcolor, $fogend)water block: a flat body (waveScale: 0, foamWidthMeters: 0, causticStrength: 0, since Source water has no swell, foam or caustics) with a gray transmittanceColor of 5% of the light at atDistance, which is $fogend in meters, since Source’s linear fog is complete there ($fogstart has no exponential counterpart and is not read) and fogColor set to $fogcolor, drawn unlit as Source draws it, with totalInternalReflection: false, since Source draws the underside as the refracted world with no mirror. $normalmap is the material’s normal channel, read alone (normalMix: 1, normalStrength 5, chosen by eye against HL2’s canal) at normalScale meters to a tile: its width times Hammer’s default 0.25 units per texel (the faces are not consulted) over $bumptransform’s scale, with $scale dropped. An AnimatedTexture on $normalmap is the flipbook (every VTF frame in one .normal.png atlas, at animatedtextureframerate) and a TextureScroll on $bumptransform the uvScroll, read as a base texture’s is. refractionScale and reflectionScale stay 1: there is no calibrated conversion from $refractamount and $reflectamount. $bumpmap (the dudv), $envmap, $reflecttint, $bottommaterial and the per-body flow, volume and surface plane are not mapped🟡
Refract ($refracttint, $bluramount)glass block (tintColor, roughness)🟡
Proxies: TextureScroll on $basetexturetransformuvScroll, the rate along the angle (see Motion)✅
Proxies: AnimatedTexture on $basetextureflipbook over an atlas of the VTF’s frames (see Motion)✅
Proxies: TextureScroll on $texture2transformuvScroll of the second texture drawn as the base, when the first does not move (see Motion)🟡
$basetexture2 of any other shader (Lightmapped_4WayBlend), $ssbump, $color2, every other proxy (Sine, PlayerProximity, TextureTransform)not carried❌
$phong, $rimlight, $lightwarptexture, a rotated or translated texture transformnot carried❌
Cable, Eyes and other shadersthe base texture only❌

A shader block’s DirectX sub-blocks are read as a DX9 HDR card reads them: >=dx90 blocks and the block named for the shader at that level override the rest, <shader>_HDR_DX9 in place of <shader>_DX9 when both are there, and the DX8 ones are ignored. Many of HL2’s floors keep their bump map, their envmap mask and their HDR tint only in those blocks (concrete/concretefloor039a reflects at a tint of 0.21 through its normal map’s alpha, not at 0.85 everywhere), so reading the top level alone made them glossy.

A feature DH cannot hold is a named warning, never a silent drop, and the report counts each: blendLayer, blendModulate, detailReplaced, secondTexture, effect, ssbump, proxies (the ones DH does not play), phong, rimLight, lightWarp, textureTransform, detailBlendMode, reflectiveSurface, unsupportedShader, unknownSurfaceProp, missingTexture, textureDecode, missingInclude, includeDepth, badPath and unresolved. An envmap’s roughness is 1 - EnvmapSmoothnessGain * tint * mask, never below MinRoughness; those and the other tuning numbers live in SourceMaterialImportDefaults.

WorldVertexTransition draws a displacement as two layers, $basetexture and $basetexture2, lerped by each vertex’s blend alpha (the alpha the mapper paints, 0 to 255). The import writes that alpha into the red of the displacement’s COLOR_1, and maps the second layer as the material’s detail block with vertexWeight: "r", so the layer is painted on exactly where Source paints it. Only a primitive whose material reads it carries the set; a plain brush face reads zero, its first layer. $blendmodulatetexture, which sharpens the cross-fade by a texture, is dropped, and a $detail on the same material gives way to the second layer. Lightmapped_4WayBlend is still drawn as its first layer.

The report counts these materials (materialImport.blendLayers) and the primitives that carry their alpha (geometry.blendPrimitives). Measured: d1_canals_01 has two, nature/blendgrassgravel002a and nature/short_red_grass, painted across 175 vertices of its 38 displacements.

A model has no vertex color to carry: a VVD holds positions, normals, tangents, texture coordinates and bone weights, and nothing else.

Light shafts, glows, energy shields and decal stains are not opaque surfaces: Source draws them with $additive (adds the texture to what is behind it), or the Modulate and DecalModulate shaders (multiply what is behind it by the texture, DecalModulate and $mod2x by twice). Their textures are black where they are empty and carry no alpha, so drawn as an ordinary alpha blend they are solid slabs. That is what the light shafts of gm_prison (models/effects/vol_light256x384.mdl, four of them, material models/effects/vol_lightmask02: UnlitTwoTexture, $additive 1, $translucent 1) were: gray gradient sheets cut across the hall.

Each one is an alphaMode: blend material with the matching blendMode and its own texture: $additive is additive (lit or unlit as its shader is), Modulate is multiply, and Modulate with $mod2x and DecalModulate are mod2x. $color is the tint and $alpha its alpha. Nothing is baked.

  • The texture’s alpha counts only where Source reads it. An additive surface blends ONE, ONE unless it is $translucent, so the alpha of one that is not is never read; when its VTF stores an alpha, the texture is written with the alpha made solid as <material>.solid.png, with the VTF’s own clamping. Modulate and DecalModulate fade toward no change by the texture’s alpha, as DH’s multiplying modes do.
  • An UnlitTwoTexture’s second texture is its detail layer. The shader multiplies the two textures (twice with $mod2x), which the detail layer’s mul (mulx2) does to the color, at the second texture’s own $texture2transform scale. DH scrolls only the base channels, so when the second texture scrolls and the first does not (the dust of a light shaft, the crackle of a Combine shield, a fence emitter’s glow), the two swap: the second is drawn as the base with its uvScroll, and the first is the detail layer. The detail layer adds no alpha, so on an additive $translucent surface the detail texture’s alpha is multiplied into its color (<material>.premultiplied.png, exact for an additive blend, which adds color times alpha); on any other surface that reads alpha it is lost, with the secondTexture warning. A material whose $detail already holds the layer drops the second texture, with the same warning.
  • $decal is a depthBias of one step, so it wins against the surface it lies on. info_overlay entities are not imported yet, so the depth bias reaches decal materials on brush faces and models only.

A surface with no $basetexture blends its $color alone.

What is left out. A material that cannot be drawn is invisible: an unlit, fully transparent material, so a model’s slot disappears and a world face that uses it is dropped like a tools face. The report names each in hidden, with why.

ClassDoes
SpriteCard and SpriteInvisible: a particle card’s color and alpha come from the particle, and its frames from the system
An additive or modulate surface with $vertexalphaInvisible: the fade is in the vertex alpha, and without it the surface would draw at full strength
An additive or modulate surface whose $basetexture is a render target or missingInvisible: its blend would lay a flat color over the frame (dev/dev_combinemonitor_3’s _rt_Camera)
$mod2x on any other shaderNot a blend. On UnlitTwoTexture it doubles the product of the two textures (mulx2 on the detail layer); the surface stays an ordinary one

env_sprite entities are not imported, so a map’s glow sprites (22 on d1_canals_01) are not drawn at all.

Measured on the 13 Source 1 maps in the development workspace (materials a map’s world and props use; a material is counted once per map):

Map$additiveModulate shader$mod2xSpriteCard and Sprite
squidgame_facility2 (lcd_timer2024_additive, glasswindow_frosted_003)05 (UnlitTwoTexture CCTV screens: not a blend)0
gm_bigcity_improved3 (glass_clear01 and two window copies)000
d1_canals_012 (combine_fenceglow, comshieldwall2)000
d1_trainstation_025 (vol_lightmask02, comshieldwall and comshieldwall2, two dev_combinemonitors)000
gm_prison1 (vol_lightmask02)000
d1_trainstation_013, of which dev_combinemonitor_3 and _b are invisible (a render target)000
The other eight0000

Proxies on those maps, played and dropped (materials, per map; gm_prison: TextureScroll 1 played, 1 dropped, AnimatedTexture 1 dropped; squidgame_facility: TextureScroll 2 played and 7 dropped, AnimatedTexture 5 dropped, Sine 5, ToggleTexture 3; gm_mallparking: AnimatedTexture 1 played and 1 dropped, TextureScroll 1 played and 2 dropped; d1_trainstation_01: AnimatedTexture 1 played, TextureScroll 5 dropped, Sine 14). Most TextureScrolls move a second texture, and most dropped AnimatedTextures sit on $bumpmap water or a refract shader.

A VMT’s Proxies block moves a material at run time. DH materials scroll and flip their textures off the shader clock (uvScroll and flipbook, see UV animation), so two proxies are played:

ProxyBecomes
TextureScroll with textureScrollVar $basetexturetransformuvScroll = rate × (cos angle, sin angle), the angle in degrees as the proxy takes it. The rate may name a material variable ($rate)
AnimatedTexture with animatedTextureVar $basetextureflipbook: the VTF’s frames packed into one atlas, ceil(sqrt(n)) columns across (FlipbookAtlas, shared), written as <VTF>.<hash>.flipbook.png; fps is animatedTextureFrameRate (30 when it names none), looping. A $selfillum base gets an emissive atlas the same way
TextureScroll with textureScrollVar $texture2transform, on an UnlitTwoTexture whose base does not moveuvScroll of the second texture, drawn as the base (see Additive and multiplying surfaces)

The flipbook is left out (the proxy stays dropped) when the material has a $bumpmap, $envmapmask, $detail or a $selfillummask, whose textures would not line up with the atlas; when the VTF has one frame; and when the atlas would pass 8192 texels or 64 cells a side. A TextureScroll of $texture2transform plays only when the second texture can lead (the base still, the surface additive or opaque); otherwise it is dropped. Every other proxy (Sine driving $alpha or $color, PlayerProximity, GaussianNoise, TextureTransform, LinearRamp, ToggleTexture) is dropped and named in the proxies warning.

The report counts each proxy: materialImport.proxies has, per proxy name, how many materials it was on and how many of those DH plays (mapped) or leaves still (dropped). A surface that needs a proxy DH lacks keeps its hiding above.

Everything under tools/ is a marker and drops its face, except tools/toolsblack, which is a surface. A VMT whose compile flags (%compilenodraw, %compileclip, %compiletrigger, %compileskip, …) mark it a marker drops its face too. tools/toolsskybox, a Sky shader and %compilesky classify as sky.

A VTF is decoded at its own size, mip 0 of the first frame, and written as an RGBA PNG through Halcyon.Imaging.PngWriter, which the DH build mips. A two-channel normal map (ATI2N) gets its blue rebuilt. A VTF whose flags ask for point sampling or clamping gets a .dh-tex-settings sidecar. Textures are streamed one at a time; no game’s textures are held in memory.

Textures stay at the VTF’s size unless the workspace sets an import texture cap (import.maxTextureSize, with per-game overrides keyed by the map’s originGame). A VTF over the cap is scaled down after the decode and before anything is derived from it: the color, the normal map (renormalized), the envmap and detail masks, and the frames of an animated texture, so the metallic-roughness, self-illumination and flipbook textures built from them come out capped too. A VTF flagged for point sampling is scaled by nearest texel so a palette stays a palette. The lightmap, the sky images and the reflection cubemaps are not materials’ textures and keep their size. The import report and the console summary count the textures the cap scaled down.

On the development PC’s Garry’s Mod, gm_construct names 204 materials: 153 cubemap patches (all of which share the barcode of the material they include), 6 tools and 1 sky, which leaves 61 material files and 85 textures (34.6 MB of PNG), written in 5.7 s from nothing, with none unresolved. gm_flatgrass names 18 and writes 15 textures in 1.7 s. Both compile clean under dh build with no warnings; SourceMaterialImportCompileTests runs that on the installed game and skips when it is absent.

dh import source-map turns a compiled Source map into a DigitalHeaven map pallet that builds with dh build and loads with the map’s own baked lighting. It reads the BSP in place from your install, so nothing of Valve’s or the map author’s is ever shipped: the pallet is generated on your machine.

Terminal window
dh import source-map --list --game gmod
dh import source-map gm_construct --game gmod --out Source
dh import source-map de_dust2 --game cstrike --out Source
dh import source-map "D:mapsmy_map.bsp" --out Source
  • --game takes a platform ID or alias (gmod, hl2, cstrike, com.valvesoftware.tf and so on) and defaults to gmod. It picks the mount stack that finds the map by name and the spawn classes that read it. --list prints the stack’s maps: name, layer, BSP version, size, whether it has a thumbnail.
  • A name is found through the same mount stack the game uses, Workshop addons included; a path reads that file alone and mounts the game around it for its materials.
  • --out is the folder the pallets are written under (the workspace’s Source folder to build them), one folder per pallet (ImportedPalletLayout.FolderOf). The map’s pallet and the base pallets its materials are generated into (com.valvesoftware.hl2 and so on) all go there, and the map’s manifest declares every pallet its barcodes reach. The importer never touches your workspace unless you point --out at it.
  • Addons fill, they never replace. The import searches the mount order: the map’s pakfile, its own Workshop item, the game, and only then every installed addon, enabled or not. A map’s content packs therefore load even when the player disabled them (gm_poop_city’s asset packs, for one, which took its missing materials from 546 to 92 and its missing prop models from 6,161 to none), and a replacement addon such as Brushwork Props Replacement never swaps HL2’s red oil drum in d1_canals_01. The report’s contentAddons names each addon that supplied a file, with its Workshop id or folder name, its title and how many files, and a warning says the same, for example content addons filled 42002 files the map and the game lack: 106449281 "Minecraft model pack" (2), ....
  • The library entry point is SourceMapImporter.Import in DigitalHeaven.Platforms.Source, which Studio’s Import page runs through dh. The command is a thin wrapper that prints the report.
  • --progress prints one line as each step starts, progress 7/14 collision, with the total the importer works out once it knows the map (the props step is counted only when props are imported). Every looping step also counts its items (progress 7/18 reflectionPlan 12/28 "cubemap11"): the entities, the texdata materials and then the faces the geometry step meshes, the models, their textures and then the placements of the props step, the cubemaps, the scene’s nodes, the lightmap’s charts, the collision’s brushes and displacements, the props that settle, the entities and their connections, the probes’ samples and cells, the visibility clusters and cells, the world lights and the files written. The count is shared by the importer’s threads and reported about ten times a second. Each step then states what it did, summary props 191 placed (182 static, 9 entity), 21 unique models, 0 missing, 0 failed, 0 left out (0.7 s), or props: 191 placed ... in the plain log. The importer reports through SourceMapImportOptions.Progress and .Summary; Log keeps printing the bare step names. The line format is in the CLI reference.
  • The pallet states which importer made it. Its manifest’s metadata holds importer (source-map), importerVersion and game (the platform ID the map was imported through, so d1_canals_01 imported through Garry’s Mod says com.facepunch.gmod) and originGame (the game that ships the map: the owner of the layer it was read from, so that same map says com.valvesoftware.hl2, while a Workshop or loose map says the importing game) and source (ImporterVersions.SourceKey: stock for a map a game ships, workshop for a Workshop item’s, local for a loose .bsp, the same key and values the Source 2 importer writes, so a map list splits a game’s maps into stock and Workshop without reading the pallet id). The version is ImporterVersions.SourceMap in Core, derived from the stage table ImporterStageVersions.SourceMap (geometry, materials, textures, models, props, logic, collision, sky, lightmap, probes, reflections, visibility, lights, manifest): its base plus every stage. When an output changes, bump the stage that makes it, never the total; the total rises by one, and Studio calls a map stale when its stamp is older than that number or missing, and offers Re-import. The re-import then redoes only the stage that was bumped: the lightmap and the reflection cook are kept while their key matches (What an import reuses). The lightmap’s key is the lighting lumps (LDR and HDR), the faces, texinfos, planes and displacements that address them, the entities (their light styles), the GLB and the luxel scale; the cook’s is the GLB and each planned probe’s name, origin and image. A change to the Content code a bake calls (the lightmap builder, the cube prefilter) bumps its stage too.

The pallet ID names the maker of the bytes: <game id>.maps.<map name>, for example com.facepunch.gmod.maps.gm_construct or com.valvesoftware.cstrike.maps.de_dust2. A Workshop map takes the item’s own ID, com.steamcommunity.workshop.<itemId>, so every map of one item lands in one pallet. A loose .bsp is local.maps.<name>.

SourceBecomesState
World faces (the split face lump)One GLB node, one primitive per material slot. Each face keeps its own lightmap rectangle, so its vertices are never shared. Winding is turned over from Source’s clockwise, and a face vbsp merged into a concave polygon is ear-clipped✅
DisplacementsTessellated from the face’s corners (the one nearest the start position first) and the vertex vectors, with Source’s alternating diagonal, smooth normals, texture UVs from the undisplaced position, and the blend alpha as COLOR_1’s red where the material paints a second layer on by it✅
Tool, nodraw, sky facesLeft out: the sky shows through where they were✅
The 3D skybox (the sky_camera area)The map’s backdrop: the room’s faces and displacements (vbsp lists no displacement in a leaf, so each is placed by its base face’s center) as one GLB part, backdrop, on the map’s own lightmap; a brush entity standing in the room as a part too; the room’s props under the backdrop’s folder; sky_camera’s origin as the anchor, its scale, and its fog (distances at full size, as Valve authors them). None of it collides. Its water, lights and animated entities are not drawn🟡
Brush entities (func_brush, func_door, …)A static node named for the entity’s targetname, else model_<n>, with its class in the node’s extras, placed at the entity’s origin and turned by its angles (the compiler stores the model about that point). A func_brush, func_wall, func_wall_toggle, func_breakable, func_lod or func_physbox that is solid as the map spawns it collides with it, however it draws (rendermode 10 is still a wall, which is how a map makes an invisible gate): a func_brush whose Solidity is 1 (never solid, like the window brushes behind a func_areaportalwindow) or that starts disabled, a func_wall_toggle that starts invisible or not solid, a func_lod whose Solid key is not 0, and a func_physbox that is notsolid or flagged debris (16384), are left out and counted. A physbox is a SOLID_VPHYSICS body in the game, so it stops a player where the map put it, and the hulls stay there when it later swings or slides. A brush entity that a func_areaportalwindow names as its target is not drawn at all: the game shows it only between the window’s fade distances, as a far stand-in for the room the closed portal hides (effects/black, tools/toolsblack, or a flat picture of the room), and it is transparent up close, so as a surface it was a black slab. The report counts them as geometry.areaPortalWindowBrushes, and their collision follows the usual Solidity rule above. A func_areaportal itself has no model in the BSP (the compiler turns its brush into the portal), and its target is a door, which keeps its brush. A door’s, a movelinear’s and a button’s piece is filed under its logic object🟡
Solid brushesOne hull shape each, cut from the intersections of the brush’s side planes, all owned by one collision object’s dh.collider standing at the origin (points in world meters), autoCollision: false✅
Displacement collisionvbsp keeps no brush for a displacement, so each is covered by slabs and prisms under its grid🟡
Player spawnsOne object carrying a dh.spawnPoint per spawn entity of the game’s classes, at the feet, yaw kept and pitch negated✅
light_environmentThe map’s sun (direction, color, intensity)✅
2D skyThe six VTFs resampled to one equirectangular .hdr (an HDR cube or 8-bit pictures decoded with gamma 2.2)✅
GMod’s painted skyA gradient .hdr fitted to what Garry’s Mod shows (see Matching the display); clouds, stars and the sun are not drawn. (The procedural sky is dark under a baked sun, so it is not used)🟡
TonemapThe map asks for render.tonemap: none, Source’s own output curve (see Matching the display)✅
Lightmaps, HDR when the map has themThe imported .dh-lightmap, origin: "imported", with the sky’s fill and the bounce baked in✅
env_cubemapAn object carrying a dh.reflectionProbe for every cubemap a face or a prop reflects, with no cap, and the matching .dh-reflcook. Faces and props name their probe instead of finding one by position, as Source binds them (see Reflections)🟡
Map thumbnail (maps/thumb/<map>.png)Copied to maps/<map>.thumbnail.png, the map’s thumbnail, with the first camera taking the mapThumbnail role and autoCapture: false, so a build packs the game’s own picture. A map the game has no thumbnail for keeps autoCapture on and is photographed from that camera✅
MaterialsThrough SourceMaterialImporter (see Importing Source Materials): each texture name becomes a dh.material in the base pallet that owns it, a cubemap patch sharing its base’s slot✅
Static props (the sprp game lump)An instance of the model’s object at the prop’s origin and angles (roll included), its skin, and its uniform scale from lump version 11. A prop in the 3D skybox goes under the backdrop’s folder; the ones flagged not to draw are left out. The solid setting decides prop collision🟡
Entity props (prop_dynamic, prop_dynamic_override, prop_physics*, prop_ragdoll, prop_door_rotating)The same instance, named for the targetname (else <class>_<index>), not static, disabled when startdisabled is set. Each one’s class, targetname, model, skin, mass, surface property and spawn flags go in the report. They stand still, no body, motion or inputs, but for a prop door, which turns (see Logic), and the physics props the engine drops at spawn, which are settled onto what is under them🟡
Prop skins (skinfamilies)A variant object for each skin a placed prop shows, inheriting the model’s object and replacing its renderer’s materials✅
Entity render color (rendercolor, renderamt)A variant object, beside the model’s, whose materials are written beside the originals as <name>.tint-rrggbbaa with the entity’s color folded into their tint. The color multiplies the light the surface outputs, so it is taken in linear light and written back as the sRGB tint that draws it (Source’s 49 45 34 on a light shaft is a fifth of its light, not a thirty-seventh). renderamt is the alpha of every rendermode but 0 (an opaque material becomes blend). Static props (the sprp lump’s own color) are not carried. The report counts tintedProps and tintedVariants✅
Prop collisionEach prop collides as the engine’s player traces give it (see Collision), written as hull shapes on the map’s collision object at the prop’s origin, angles and scale (a piece over 255 corners is reduced as the world’s hulls are): the first .phy solid for a solid 6 static prop, a prop_physics or a door; every solid at its bone for a prop_dynamic with several; a box for solid 2 and 3. Everything else, a model with no .phy among them, is counted in the report and gets nothing, as in the game. prop_ragdoll is left out. A prop a mover carries, a door among them, has its hulls on its own instance instead, so they turn and slide with it; a physics prop’s stand still once it moves🟡

Doors, buttons, triggers and the logic entities are wired: see Logic. Everything else is counted in the report and not imported: detail props, spinning brushes and trains (kept as static brush nodes and props), teleports, decals and overlays, soundscapes, NPCs and nodes, the world’s fog (env_fog_controller). What it would take to make the rest work is researched in Source logic and ambience. Water brushes are imported: see Water.

Source’s water is its brushes. A brush whose contents are Water or Slime is a volume of water, and the faces vbsp draws on it are only its skin, so the importer reads the volume rather than the skin. Each world water brush outside the 3D skybox’s room that fills at least 90% of its axis-aligned bounds becomes a water box: an object spelled as the boxBrush sugar, named water1, water2 and so on, placed at the center of the brush’s bounds with the bounds as its size, over the brush’s water material slot. The engine cuts a box over a water slot into a water surface and mints a body from the box itself, so the body runs from the brush’s floor to its surface and no further. Reading the surface faces instead merged every sheet of a map into one body spanning the whole map, with the water’s floor at the height of its lowest surface: gm_poop_city’s 33 sheets became one 644 by 450 by 714 meter body that held the spawn.

  • The material is that of the brush’s highest side that wears a water material (its surface), else the surface texinfo of the water data of the leaf the brush sits in. A brush whose sides and leaf name no water material is not boxed. Slime is water with the material of its own sides.
  • Adjacent brushes merge. vbsp cuts a pool along its BSP planes. Boxes of one slot that agree on two axes and touch or overlap on the third join, repeatedly, so a pool cut in ten pieces is one box. Brushes that differ in surface height or floor stay separate: their union is not a box.
  • The surface faces are left out. Every water face that lies inside a boxed brush’s bounds is dropped from the geometry, so the water is not drawn twice and no second body is minted from the faces. A water face that lies inside no boxed brush stays.
  • A brush that is not a box keeps its faces. A brush that fills under 90% of its bounds (a sloped floor, a wedge) is left as the faces it had, and the report counts it as waterBrushesNotBoxes.
  • A brush with no surface face is still water. A brush inside another volume gets no face from vbsp, but Source floods it all the same; the report counts these as waterBrushesWithoutSurface and each becomes a box.

The geometry stage’s version was bumped, so maps imported before this are imported again. The report counts waterBrushes, waterBoxes, waterBrushesBoxed, waterBrushesNotBoxes, waterBrushesWithoutSurface and waterFacesReplaced. Brush entities that make water (func_water_analog) and a water brush’s currents are not imported; the settling of props onto water brushes (see Settling physics props) is unchanged.

The import reads a map’s linear movers, buttons, triggers and logic entities as objects carrying DigitalHeaven’s logic components, and their outputs as action lists. Each convention is Source SDK 2013’s own, and the source file is named where it is subtle. The pass is ValveLogic in Content, which the Source 2 importer runs too: each importer hands it its entity records and what each entity’s geometry is (here a brush model and its piece of the world model).

SourceBecomesState
func_doorAn object at the entity’s origin carrying a dh.mover and nothing else, named for its targetname (else func_door_<index>). Its direction is AngleVectors of movedir (else angles), so a pitch of -90 is up. Its distance is the brush’s size along that direction less lip; the engine pads the lump’s bounds by a unit a side when it loads them and CBaseDoor::Spawn takes the two back off (doors.cpp). Its duration is the distance over speed (100 when it is 0). The brush piece keeps its entry in the world instance’s children, with parent naming the mover, static: false, and the door’s hulls as its dh.collider, and the mover carries it: the piece is drawn at the carry with its closed-pose lightmap texels, its hulls are kinematic bodies, and a player standing on it rides.. A door that draws nothing (rendermode 10) has no piece to carry, so the mover is its own body: a box collider of the brush, a trigger when the door is passable, which moves today🟡
Door start stateA door that spawns open (spawnpos 1, or the obsolete start-open flag 1) is written with the mover’s startOpen set: the mover stands closed where the map built it and spawns open, where Source spawns it. Source’s Open stays the mover’s open, and the output Source fires at the top stays onOpened✅
Door auto-returnA wait of 0 or more, unless the door toggles (flag 32), wires the mover’s top output to a delay of wait and a call back down. A wait of -1 stays open✅
Use opens (flag 256)A <door>_use object under the mover, carrying a dh.button over a box of the brush’s bounds, whose onPressed opens the door, or toggles it when the door toggles (CBaseDoor::DoorActivate)✅
Touch opens (flag 1024)A <door>_touch trigger over the brush’s bounds grown by 4 units, beside the door and not under it, whose onStartTouch opens it the same way✅
Locked doors (flag 2048)DigitalHeaven has no lock. A door something unlocks is written unlocked, with a note. A door nothing unlocks takes no Open, Toggle or Use, and no use or touch helper🟡
func_movelinearA mover the same way, with movedistance (else its travel) as the distance. One that starts part way (startposition between 0 and 1) starts closed, with a note🟡
func_door_rotatingThe same bodiless mover over its piece, at the entity’s origin (the hinge Hammer’s origin brush puts there) and turned by its angles, with motion: rotate. Its angle is distance in degrees, negated by flag 2 (backwards), and its duration is the distance over speed in degrees a second (100 when it is 0), as AngularMove divides the angle’s change by the speed. Its axis is the one CBaseToggle::AxisDir picks (subs.cpp): flag 64 (Hammer’s “X axis”) is a roll, about the entity’s X, flag 128 (“Y axis”) a pitch, about its Y, and otherwise a yaw, about the world’s Z. Source adds the turn to one of its angles, which is exactly a turn about that axis in the object’s frame, so the axis is written in it. It is wired as a func_door is: start open, toggle, use and touch opening, wait, the lock, and the outputs. spawnpos 1 spawns it open: the mover stands where the map built it with startOpen set. The obsolete flag 1 spawns it open with its turn reversed and that end reported closed (CRotDoor::Spawn): the mover stands at that end, closed, and its piece is turned there by its override’s rotation🟡
Two-way doorsA yaw func_door_rotating without flag 16 (one-way), and a prop_door_rotating with opendir 0, opens away from whoever opens it (CBaseDoor::DoorGoUp, CPropDoorRotating::BeginOpening). DigitalHeaven’s mover has one open pose, so each opens the way Source opens it with no activator: a func_door_rotating by its distance, a prop door forward. logic.twoWayDoors counts them, and the import warns🟡
prop_door_rotatingA bodiless mover at the prop’s origin with motion: rotate, an axis of the object’s up (Source fixes the hinge axis to Z, CPropDoorRotating::Spawn), and the prop’s instance filed under it, which it carries. The door model is built around its hinge, so the instance’s origin is the hinge. Its turn is distance (90 when it is 0), and speed is degrees a second (100 when it is 0). Forward lowers its yaw, or raises it when the hinge is on the door’s left (CalcOpenAngles, IsHingeOnLeft: the corner of its world bounds farther from the hinge lies along its right). It opens forward, or backward with opendir 2. spawnpos 1 (or flag 1) and 2 spawn it open, at Source’s unswapped forward and backward ends, so the mover stands where the map built it with startOpen set and turns back to closed; ajar (3) is not an end and spawns closed, with a note. returndelay other than -1 closes it that many seconds and a tenth after it opens (DoorOpenMoveDone). Its Open, OpenAwayFrom, Close, Toggle and Use inputs map, OnFullyOpen and OnFullyClosed fire on arrival, and OnOpen, OnClose, OnLockedUse and the blocked outputs are dropped. A <door>_use button riding it, over a box of the model’s hull, opens it, or toggles it with use-closes (flag 8192, CBasePropDoor::OnUse); flag 32768 (ignore use) gets none. Locked (2048) is a func_door’s lock. hardware picks a bodygroup, which the model import does not: the model’s first body shows. A prop door whose model was not placed stays static🟡
Double doorsCBasePropDoor::Activate: in lump order, each named prop door takes as its slaves the prop doors its slavename (else its own name) matches that have no slaves of their own. A use on either half reaches the master, and a door’s Open, Close and Toggle reach its slaves, so the use buttons and the inputs call both movers. logic.doorPairs counts the masters✅
func_rotatingA bodiless mover over its piece at the entity’s origin (the pivot), with motion: spin, as a func_door_rotating is built. Its axis is picked by CFuncRotating::Spawn (bmodels.cpp): flag 4 (Hammer’s “X Axis”, SF_BRUSH_ROTATE_Z_AXIS, angular velocity QAngle(0,0,1)) is a roll, about the entity’s X, flag 8 (“Y Axis”, SF_BRUSH_ROTATE_X_AXIS, QAngle(1,0,0)) a pitch, about its Y, and otherwise a yaw, about the world’s Z: the axes a rotating door’s flags 64 and 128 pick, so it reuses their code. Its rate is maxspeed in degrees a second (100 when it is 0), negated by flag 2 (reverse). Flag 16 (Acc/Dcc) makes it ramp: SpinUpMove adds 0.2 * maxspeed * friction every 0.1 s, friction being fanfriction / 100 (1 when 0), so its duration, the time to reach its rate, is 1 / (2 * friction) seconds (2.5 s at the default 20). SpinDown takes twice as long and DigitalHeaven has one time for both, so it stops quicker than Source’s does. Without flag 16 the change is at once and the duration is the 0.01 s minimum. Flag 1 (start on) wires a <fan>_start dh.mapStart to its open. Start, Stop and Toggle are open, close and toggle; SetSpeed and Reverse are dropped, since DigitalHeaven’s spin has one rate and one direction. Flag 64 (not solid) leaves its piece no hulls, and flag 32 (hurts) is noted: DigitalHeaven has no damage. logic.spinners counts them🟡
func_buttonAn object at the entity’s origin carrying a dh.button and a dh.collider box over the brush’s bounds, the static body a use lands on, when it is used (flag 1024). A touch-only button (flag 256) is a trigger over its bounds grown by 4 units, and its piece keeps its hulls. One that is locked and that nothing unlocks, or that neither use nor touch presses, stays static. The press travel is left out: no mover is added, because the travel is a few units of decoration and DigitalHeaven’s button latches its press on its own🟡
Button wait -1It stays pressed, so OnPressed passes through a <button>_once latch: a dh.orGate whose setA the press writes true, firing onTrue on the first edge and never again✅
trigger_multipleAn object carrying a dh.collider box over the brush’s bounds whose state is trigger. OnStartTouch, OnStartTouchAll and OnTrigger become onStartTouch, and OnEndTouch and OnEndTouchAll become onEndTouch: DigitalHeaven fires on the first player in and the last out, which is Source’s ...All🟡
trigger_onceThe same, through a <trigger>_once latch, since it removes itself once it fires (CTriggerMultiple::ActivateMultiTrigger with wait -1, triggers.cpp). Its end-touch outputs are dropped✅
Trigger filtersOnly a trigger players touch is imported: flag 64 (everything), or flag 1 or 512 without 32 (clients in vehicles). One that starts disabled stays static, since a trigger has no enable. A filtername is not read, and every player touches the trigger🟡
trigger_teleportOne object: the brush’s bounds as a trigger dh.collider, a dh.trigger (enabled from StartDisabled), and a dh.teleport whose destination names the target object, with onStartTouch -> teleport wired first so the map’s own OnStartTouch lists run after the move, as Source’s queued outputs do. Enable, Disable and Toggle are the trigger’s enable, disable and toggle; OnStartTouch, OnStartTouchAll, OnEndTouch and OnEndTouchAll map as a trigger’s do. Velocity is kept (velocity: keep): CTriggerTeleport::Touch passes no velocity outside HL1. Facing is the destination’s (facing: destination), except with a landmark or flag 32, which keep it. A player’s feet are not lifted: VEC_HULL_MIN is (-16, -16, 0), so -WorldAlignMins().z is zero, and DigitalHeaven’s destinations are feet poses. A target that names nothing stays static. logic.teleports counts them🟡
Teleport flagsFlag 1 (clients) and 512 (clients out of vehicles) are touchedBy: players, 8 and 1024 (physics objects, debris) are props, both together or 64 (everything) are everything, and a trigger that only NPCs, pushables, allies, bots or vehicles touch stays static. Flag 32 is both “preserve angles” and “only clients in vehicles” (PassesTriggerFilters runs first), so a walking player never passes it and the angles are kept. The engine scans only players until props land, so a props or everything trigger fires for players alone: logic.teleportsTouchedByProps counts them. A filtername is noted and not imported, apart from a filter_activator_class of the player, which is what DigitalHeaven’s trigger already does🟡
Teleport destinationsThe target is the first entity in lump order its name matches (FindEntityByName, not the fan-out a connection uses). An info_teleport_destination is a plain object at its origin and angles, parented when its parentname names a mapped node. An info_target, info_landmark or any other class a teleport names becomes one only when something names it, and one that moves on its own (npc_*, a path mover) is placed where it spawns, with a note. A class the pass already maps (a door, a button) is its own destination. logic.destinations counts the objects made✅
landmarkdh.teleport.landmark names the info_landmark object. Source keeps toucher - landmark and adds it to the target unrotated; DigitalHeaven turns the offset by the landmark-to-destination frame change, so the destination is an object with the landmark’s rotation: the target’s own when it is turned that way, else a second <target>_landmark object at the target. The frame change is then the identity✅
trigger_teleport_relativeA trigger as above, with a <trigger>_destination object at the brush’s center plus teleportoffset, velocity: zero and facing: keep (CTriggerTeleportRelative::Touch). The brush’s own angles are ignored🟡
point_teleportAn object at its origin and angles carrying a dh.teleport (empty destination, so itself; facing: destination; velocity: keep), and its Teleport input is teleport. A target of !activator is an empty subject, whoever fired the input; a named entity is the subject when the pass wrote an object for it, and a prop or an NPC has none. !player and the other procedural names are decided while the game runs and stay static, as does a target with a move parent, which CPointTeleport::EntityMayTeleport refuses. Flag 1 (to spawn position) makes a <target>_spawn object at the target’s pose and the destination is that; flag 2 (into duck, episodic only) is dropped. logic.pointTeleports counts them🟡
logic_autoAn object carrying a dh.mapStart. OnMapSpawn, OnNewGame, OnMultiNewMap and OnMultiNewRound become onMapStart, since every DigitalHeaven load is a new game. OnLoadGame, OnMapTransition and OnBackgroundMap are dropped. One with a globalstate stays static✅
logic_relayA dh.timer with no delay: Trigger is fire, OnTrigger is onTimer, and the activator rides through. One that starts disabled is a dh.andGate whose A is its enable: Trigger pulses B true then false, so onTrue fires only while Enable has set A. One that fires once (flag 1) is a latch. OnSpawn adds a dh.mapStart. A relay that starts enabled takes no Enable or Disable🟡
logic_timerA <timer>_clock object carrying a dh.timer of RefireTime and a dh.mapStart that starts it, refiring itself from onTimer, and the timer’s own object, a dh.andGate whose A is the enable. Each tick pulses B, so OnTimer (onTrue) fires only while enabled, and Enable, Disable and FireTimer are exact. A random time waits the middle of its range, with a note🟡
parentnameA prop filed under a logic object is an instance with that parent, a brush piece is an override with that parent, and a logic object takes the parent itself. A mover carries all three. A prop a mover carries, a prop door’s own or one parented to a door, has its hulls on its instance as its dh.collider, in its own frame, rather than on the map’s collision object, so they move with it✅

Connections. Each Source connection, target,input,parameter,delay,timesToFire split on the escape character or a comma (CEventAction in cbase.cpp), becomes one connection on its output’s object: a delay action first when its delay is not 0, then one call per object it reaches. Targets are resolved at import:

  • A name reaches every entity it matches, case-insensitively, and a trailing * matches every name it starts (NamesMatch). The report counts wildcards and fan-outs.
  • A name that matches nothing is read as a class name, as the event queue does (CEventQueue::ServiceEvents).
  • !self and !caller are the entity firing. !activator, !player and the rest are decided while the game runs, so they are dropped.
  • Inputs map to the target’s DigitalHeaven input: a door’s Open, Close, Toggle and Use (and a prop door’s OpenAwayFrom), a relay’s Trigger, Enable and Disable, a timer’s Enable, Disable and FireTimer, a trigger’s Enable, Disable and Toggle. A parameter is ignored.
  • A timesToFire other than -1 is counted and fires every time: DigitalHeaven has no fire count.

Anything else is dropped. logic.dropped in the report names each one by entity, output, target and input, with why, and logic.connections counts the mapped and dropped connections by input name and by reason. The classes that need an engine feature DigitalHeaven lacks (trains and paths, damage, push volumes, counters, cases, branches, compares and sounds) stand still and are counted in logic.leftStatic.

What moves. Every door: a sliding or swinging brush door carries its piece, drawn at the carry with the lightmap it was baked with shut, and a prop door carries its instance. Their hulls are kinematic bodies, so a closed door blocks and an open one lets a player through. A func_rotating spins. The trains and the rotating buttons stand still.

MapMoversButtonsTimersRelaysTriggersMap startsConnections mapped of total
gm_prison15310100055 of 80
gm_mallparking22500206 of 13
squidgame_facility1085821217356 of 858
d1_trainstation_0210241440413 of 307
gm_fork0400010 of 30
gm_bigcity_improved29300010 of 266
gm_construct0100000 of 24

Movers counts the brush movers, sliding and rotating; the prop doors and the double doors are counted in As built: phase 2.

Most of what is dropped is a target DigitalHeaven has no logic for: logic_case and math_counter on squidgame_facility, NPCs and AI goals on d1_trainstation_02, and gm_fork’s animated prop_dynamic garage doors.

The teleports import on the four maps that carry them: gm_bigcity_improved has 90 of its 92 trigger_teleports (the other two name a target nothing has), with 80 destinations, and its buttons’ Enable and Disable reach their triggers (166 of 266 connections mapped); gm_poop_city has 113 of its 114 point_teleports, all on !activator; d1_trainstation_02 has its 4 and squidgame_facility its one of each.

Source draws a map’s skybox room first, from sky_camera.origin + eye / scale with the player’s rotation and the room’s own fog, clears depth, and draws the world over it. DigitalHeaven’s backdrop is that model, so the import writes the room into one:

  • The room. SourceSkyArea finds the area sky_camera stands in (none when a player spawn shares it) and its faces through that area’s leaves. vbsp lists no displacement in a leaf, so each displacement is placed by its base face’s center. Those faces mesh into one more root node of the map’s GLB, backdrop, with their own charts in the map’s lightmap, and the map’s backdrop.parts names it. A brush entity whose bounds center is in the room becomes a part too and loses its collision. The room’s brushes and displacements are left out of collision.
  • Its props. Static and entity props in the room are placed like every other prop, under a folder record named backdrop that backdrop.folder names, so they never stand in the world.
  • The camera. sky_camera’s origin through ValveWorldSpace is the anchor, and its scale (16 when absent) is the scale.
  • Its fog. sky_camera’s fogenable, fogcolor, fogstart, fogend and fogmaxdensity become backdrop.fog, the distances in meters. Valve divides them by the scale inside the room, so they are full-size distances, which is what the backdrop’s fog measures. The world’s env_fog_controller is still reported only.

On gm_construct the room is 2,662 faces (21 of them displacements) and 157 static props at scale 16, with fog from -101.6 m to 8,128 m. The 21 displacements used to land in the world, floating 280 m above the map with 7,600 collision prisms under them; the map now has 4,957 hulls where it had 12,754. On gm_flatgrass the room’s 16 displacements were the map’s only ones. Photographed from spawn1 and view1 against Garry’s Mod, the distant city, the hills and the trees past the walls are back where the game draws them.

Source adds cube x mask x tint to a surface at full strength, from one cubemap that vbsp bound to that face. That is far more than the 4% a dielectric reflects head-on, so a glass tower reads as sky and buildings, not as a dark pane. The import keeps Source’s binding rather than letting DH pick probes by position:

  • Probes. Every env_cubemap that something reflects becomes an object carrying a dh.reflectionProbe, named cubemap<index>, with projection: "none" and its position at the cubemap’s origin. It has no scale, capturePoint, boxProjection or importance, and the cook marks it unboxed. A map may hold up to 1,024 probes, Source’s own ceiling, so none is dropped. A cubemap whose image is missing or unreadable is left out and counted, and what used it reflects none. Every probe of a map is cooked at one resolution, the largest its cubemaps ask for (64, 128 or more, up to 128), with the smaller images resampled up, because the engine keeps only the probes that match the first one’s resolution and a map whose mapper built some cubemaps at 128 and the rest at 64 would otherwise reflect the sky almost everywhere.

  • Faces. vbsp writes a cubemap-patched copy of a face’s material (<material>_<x>_<y>_<z>) for every cubemap it binds. The patch shares its base’s slot, so world primitives are split by (material slot, cubemap). Each primitive bound to a cubemap carries the glTF extras {"dh":{"reflectionProbe":"cubemap<index>"}}. The split applies to the world, the backdrop and brush entities alike.

  • Props. A prop whose model draws a reflecting material is placed with a root dh.renderer whose reflectionProbe names the cubemap nearest the point Source lights it from. That point is the origin of the entity its lightingorigin names (an info_lighting, the first entity with that targetname). For a static prop it is the lump’s lighting origin when vbsp flagged it (STATIC_PROP_USE_LIGHTING_ORIGIN). Otherwise it is the prop’s own origin. The cubemaps a prop alone reflects are kept as well.

  • Materials. The envmap block carries what the shader does after the lookup:

    FieldSourceNotes
    tint$envmaptintThe linear value each shader uses (see below), written in sRGB as every color of the format is. A channel above 1 cannot be written yet and is clamped; the report counts those materials and the brightest value
    mask$envmapmask, $basealphaenvmapmask, $normalmapalphaenvmapmaskThe first one set wins, in that order, as the shader combos allow only one. $envmapmask is its own texture, read as data. A base-alpha mask is { "from": "baseAlpha" }, and the material is marked alphaMode: opaque when nothing else set it, so the compiler never takes that alpha for blending. A bump map’s alpha does not survive the compile (it holds the normal’s length there), so it is copied into textures/<material>.envmask.png and bound as the mask texture
    fresnel$fresnelreflection (lightmapped shaders), 1 - $envmapfresnel (VertexLitGeneric with $phong)The strength head-on, rising to full at grazing angles; 1, no falloff, on plain VertexLitGeneric, which has no Fresnel term
    contrast, saturation$envmapcontrast, $envmapsaturationlerp(c, c*c, contrast), then lerp(gray, c, saturation). The skin shader has neither
    lightScale$envmaplightscaleNot in the SDK 2013 shaders; read when a material sets it

The tint’s color space. The SDK 2013 shaders do not agree. LightmappedGeneric (and WorldVertexTransition, which shares its helper) hands $envmaptint to the pixel shader as it is (SetEnvMapTintPixelShaderDynamicState in lightmappedgeneric_dx9_helper.cpp), so the authored number is already a linear multiplier. VertexLitGeneric and UnlitGeneric convert it from gamma (SetEnvMapTintPixelShaderDynamicStateGammaToLinear reads GetLinearVecValue). That is GammaToLinear in color_conversion.cpp: a 2.2 curve over 256 steps, and 1 from 0.95 up, so a tint above 1 is clamped. VertexLitGeneric with $phong draws through the skin shader, which passes the tint as it is again. The import takes the linear value each shader would use and encodes it to sRGB for the block.

The report counts the probes, the ones only props reflect, the props bound, the cubemaps left out as unused or unreadable, the primitives before and after the split, the materials given an envmap block and those whose tint is above 1.

A Source luxel is byte * 2^exp / 255, linear. The importer prefers FACES_HDR and LIGHTING_HDR, else the LDR lumps, sums each face’s light styles that are lit as the map starts (see below), and hands every lit face to ImportedLightmapBuilder as a chart:

  • Flat faces. L0 is the flat block times the calibration, with no direction.
  • Bumped faces. The flat block is the light at the face’s geometric normal. The three bump blocks are taken as three lights along Source’s bump basis (g_localBumpBasis, turned to world space as GetBumpNormals does with the texture’s s and t vectors), weighted by their luminance, and their sum is the texel’s one MonoSH direction. L0 is then divided by the lobe the atlas evaluates toward the geometric normal, so the surface reads exactly the flat value where a normal map is flat and the map’s variation rides on the direction.
  • ttt_severed and its kind store an unused extra block per style; BspFile.LightmapExtraBlocks finds it.

Light styles. A face’s lightmap holds one block per style, and the engine shows the sum of each block times its style’s brightness (a is dark and m is 1). vbsp gives every light with a targetname a style from 32 up, one per distinct name (SetLightStyles, vbsp/writebsp.cpp), and writes it back into the entity lump’s style key; the light’s own spawn then sets that style’s pattern (CLight::Spawn, server/lights.cpp): a when spawnflags bit 1 (initially dark) is set, else its pattern, else the defaultstyle preset it had before vbsp, else m. Styles 1 to 31 are the world’s preset patterns (flicker, pulse, strobe). SourceLightStyles reads the entity lump the way the engine spawns it, in order, so the last light on a style sets it, and the imported L0 (and the bump blocks that give the direction) is the sum of:

  • style 0, always;
  • a preset style (1 to 31) at the mean of its pattern, not its first frame: the atlas is one still picture, and the mean is what an animated light looks like on average;
  • a switched style (32 up) at the mean of its light’s pattern, which is 1 for a plain light, and not at all when its light starts dark.

A style no light entity sets counts as lit. The report counts faces with extra styles, faces and blocks that took extra light, blocks left out because their lights start dark, the dark styles and the faces on each style. Worldlights on a style that starts dark are not written as lights either, so a lamp the map opens switched off is not lit on props. The leaf ambient cubes need no such pass: vrad computes them from style 0 alone (leaf_ambient_lighting.cpp), so the probe group already matches the game.

Gap: no runtime switching. DH has no light styles for imported lightmaps, so a lamp the map switches later (a button, a trigger, a pattern input) does not change the baked light, and a start-dark lamp stays dark. A map that turns lights on from its own logic after load shows them off.

Because the atlas holds Source’s direct light, ambient and bounce, the map sets bakeSun: "mixed" and bakeAmbient: the sun and the lamps below are in the atlas for the surfaces it covers and live on everything else, so dynamic objects and unlit surfaces still get them.

The leaf ambient cubes (the HDR lump when the map has one) become maps/<map>.dh-probegroup, written through LightProbeGroup and added as an object carrying a dh.lightProbeGroup with contents: "indirect" (the cubes hold no direct light, and bake is left out). One probe is written per sample, at its position, with its six-face cube turned to DH’s axes and projected to order-2 harmonics by AmbientCube.ToShL2. One cell is written per leaf the world tree reaches, with its bounds. A cell’s list is its own samples, or the leaf’s refLeaf samples where vrad shared them. The tree’s planes, numbered from the head node, are the partition. The report counts probes, cells, solid and redirected cells, partition planes and depth.

vvis’s cluster visibility becomes maps/<map>.dh-vis (VisibilitySet), beside the map and shipped in the pallet as it is. It holds the world tree’s planes as a partition (the same one the probe group is cut from, built once by SourcePartition), each cell’s cluster (a leaf’s signed cluster; -1 is solid rock), and every cluster’s potentially-visible row still run-length coded as vvis wrote it (a nonzero byte is a literal, a zero is followed by a count of zero bytes). A row the lump cannot give, whether its offset is 0, out of range or does not decode to a whole row, is written as seeing every cluster. A map with no visibility lump gets no file and a warning. The report counts clusters, cells, solid cells, planes, open rows and the file’s bytes.

At run time the camera’s cluster picks a row, decoded once per cluster change, and a placed object is skipped by the camera’s opaque and blended passes when none of the clusters its bounds touch is in the row. The bounds are walked through the partition, so a moved or spawned object is judged the same way as an imported one. The sun’s shadow cascades and the occlusion prepass still draw it, and it stays submitted, so loading and the memory budget still see it. Nothing is hidden when the camera is outside the map or in solid rock, when the map’s own geometry has been moved, hidden or removed, for an object with a skinned part or a bone pose, or for one that touches more than client.render.pvsMaxClusters clusters (64). client.render.pvsCulling (on) turns it off. Areaportals are not read: every portal counts as open, which only draws more.

The runtime-add world lights become mixed lights: point lights as point, spotlights as spot (cone from the stop dots), emissive surfaces not flagged as already in the cubes as points. A worldlight’s I / d^2 is a luxel’s scale, so its DH intensity is I * 0.0254^2, with the color from I / peak. Range is where the light falls under a luxel of 0.005, or the light’s own radius if smaller. A quadratic-only light uses physical falloff; any other attenuation inherits the map’s, and the report counts those. Sky and sky ambient entries, flagged emissive surfaces and lights in the 3D skybox’s area are skipped with a log line. Switched styles are plain lights, except those that start dark, which are left out.

Calibration. One number maps a Source luxel to the atlas’s units: SourceLightmapImporter.AtlasUnitsPerLuxel, 0.98 (Content’s ImportedLightmapBuilder.AtlasUnitsPerLightUnit, which the Source 2 importer shares), and the same number scales the sun’s intensity so a realtime sun and an imported lightmap agree. The report records the value used (lightmap.atlasUnitsPerLuxel), and --luxel-scale <n> overrides it for a measuring run.

It was measured, not derived. A sealed 1056 unit room with a point light (brightness 300, quadratic falloff) 256 units above its floor was compiled with the game’s own tools (vrad -hdr -bounce 0), so the floor holds only the light’s direct term, I / d^2 = 0.1795 in luxels. It was imported, and the same room was built with no lightmap and a realtime DH point light at the same place with the equal I / d^2 in meters (the worldlight intensity 11764.7 times 0.0254 squared, 7.59, physical falloff). Both were photographed offscreen from one camera looking straight down at the floor under the light with tonemapping off, and the mean of the center 64 by 64 pixels compared: 41.89 imported against 41.60 realtime in sRGB 8-bit red, 2.0% brighter in linear light at a scale of 1. So a luxel is 0.98 atlas units: within the readout’s two percent, a Source luxel is one unit of the realtime light’s I / d^2. The sun room was not measured separately; the sun takes the same number.

Source writes radiance x tonemap scale straight to the back buffer (FinalOutput in the SDK’s common_ps_fxc.h) and Garry’s Mod reports a tonemap scale of 1.0 in both gm_construct and gm_flatgrass (render.GetToneMappingScaleLinear(), read at every capture). So an imported map asks for render.tonemap: none (SourceMapDefinitionBuilder.Tonemap): the engine’s default curve compresses a surface Source shows at linear 0.5 down to 0.35, which is most of why an import read flatter and duller than the game. The luxel calibration above was already measured with tonemapping off.

A painted sky is the colors of env_skypaint as linear values (no gamma decode), times hdrscale, times a measured gain (SourceSkyImporter.PaintedGain, 1.45), fading from the bottom to the top color by min(1, (elevation / PaintedFadeDegrees) ^ fadeBias). The gain and the 50 degree fade were read back from pixels with the sky’s colors, scale and bias driven live in Garry’s Mod; at the zenith the display is 1.46 and 1.43 times color x hdrscale on gm_construct and gm_flatgrass, in every channel. The painted sky’s mean now lands within 3% of the game on both maps. The fit is exact at the zenith and approximate near the horizon, where the game’s fog and its distant 3D skybox also act.

A prop is a model, and a model is a dh.object. The import decodes each model a map’s props name from its .mdl, .vvd and .dx90.vtx (.dx80.vtx or .sw.vtx when those are all there is) and writes two files into the pallet that owns the layer that served it, the same rule as materials: the maker of the bytes. An HL2 model lands in com.valvesoftware.hl2, a GMod one in com.facepunch.gmod, and a Workshop model in its item’s pallet. A map’s pallet names the object by barcode, for example com.valvesoftware.hl2:objects/models/props_c17/oildrum001.dh-obj, and its manifest declares the pallet, so every map that uses a model shares one import.

FileWhat it is
meshes/models/<path>.glbThe geometry, in DH’s meters and axes, one root node named for the model
objects/models/<path>.dh-objThe mesh as its model, and a dh.renderer on each drawn part binding a material to each slot by its name
objects/models/<path>.skin<N>.dh-objA skin: inherits the object above and binds its own materials to the slots that differ
  • Geometry. Level of detail 0, with every body group at its default (the first model of each body part). The vertex file’s fixup table is applied, so the vertices are in the order the strips name them. One primitive carries each skin reference, and each triangle is turned over from Source’s clockwise winding.
  • Skeleton and weights. A model with more than one bone carries its bones under a skeleton node (rest pose as the inverse bind matrices) and up to three weights a vertex. A single-bone model is rigid and carries neither, so it draws as plain geometry.
  • Spawn pose. A prop is drawn in the first frame of its model’s first sequence, as the game draws it, not in the pose the mesh was authored in. The two differ on a door: door01_left’s door bone sits a quarter turn open in the mesh and its idle sequence turns it shut, so a prop imported in the reference pose stood open, and the two leaves of a double door (a mapper sets their angles a half turn apart) swung opposite ways. The import reads that frame (raw and run-length bone channels, sectioned animations), poses the bones, and skins the vertices into it, so the skeleton’s rest pose is the spawn pose. A delta sequence, an all-zero one, an animation in another block, and a model with one bone are left in the reference pose. A prop_door_rotating that spawns open is turned there by its logic: its mover spawns open (startOpen) and carries the instance.
  • No tangents. A model’s stored tangents are a frame for Source’s own shaders, not the basis glTF means: on a crate they run along V, flip sign from face to face, and on half the vertices lie along neither axis. The mesh loader generates tangents from the UVs, as it does for the world.
  • Materials. Each texture name is looked for under the model’s $cdmaterials folders in order, and the first folder that holds the VMT wins. The material importer turns it into a dh.material in the pallet that owns the VMT, and the object’s pallet declares that pallet too.
  • Skins. A skin is not a property of an instance: the model’s skin table says which texture each skin reference draws, so a skin is the same mesh with different materials. DH’s way to say that is an object that inherits another and rebinds the slots its renderers name, so each skin a placed prop shows becomes a variant object, and the placement names the variant. The pallet grows with the skins maps use.
  • Cache. The GLB is recorded in the pallet’s import index under a hash of the model’s three files and the model importer’s own version, so a second import of any map decodes only the models that changed, and the import cache serves a GLB another map or an earlier import decoded. Every model the props name is loaded and decoded on every core before the first prop is placed. The summary counts the models decoded, reused and taken from the cache.

Placement is described in the table above. A prop whose model is in no layer, or cannot be decoded, is left out and named in the report. --no-props skips the whole step: no model is imported and no instance is written.

Collision. A model’s .phy is a VPhysics surface: an IVP bounding tree whose leaves are compact ledges, each one convex piece (triangles over a shared point array, in IVP’s axes and meters; the pieces are stored turned half a turn about X from the mesh, measured on an oil drum, a chair and a door). The import reads every piece as a convex hull and places it with the prop. What each prop collides as is what the engine’s player traces do with it, read from the SDK 2013 sources (game/server/props.cpp, game/shared/collisionproperty.cpp, game/shared/baseentity_shared.cpp, utils/vbsp/staticprop.cpp) and the engine’s staticpropmgr.cpp, enginetrace.cpp and mdlcache.cpp (not in the SDK; read from the engine source mirror):

PropsolidCollides as
prop_static6The first .phy solid in the model’s frame, at the prop’s origin and angles. A model with no physics gets nothing (the engine warns “SOLID_VPHYSICS static prop with no vphysics model” and makes it SOLID_NONE)
prop_static2A box in the world’s axes around the model’s render bounds (view_bbmin/view_bbmax, else hull_min/hull_max) as the prop turns: the engine’s TransformAABB over the model-to-world matrix, never turned with the prop
prop_static0, or any otherNothing (an unlisted value is a “bogus SOLID_ flag” and becomes 0)
prop_dynamic, prop_dynamic_override6A model with one solid, the first solid in the model’s frame. A model with several gets a bone follower per solid, each at its bone’s frame at spawn (CDynamicProp::CreateBoneFollowers); a solid whose name is no bone gets none
prop_dynamic, prop_dynamic_override2The model’s hull box (hull_min/hull_max) at the origin, in the world’s axes however the entity is turned (IsBoundsDefinedInEntitySpace is false for SOLID_BBOX)
prop_dynamic, prop_dynamic_override3The hull box turned with the entity
prop_dynamic, prop_dynamic_override0, 4, 5, or no solid keyNothing. A key the map did not write is 0 (the FGD’s default of 6 is Hammer’s, not the engine’s); 4 and 5 have no trace code. Spawn flag 256 (collision disabled) is nothing; startdisabled only hides the prop
prop_physics*anyThe first .phy solid (PhysModelCreate takes solids[index], the first). Flag 4 (debris, the COLLISION_GROUP_DEBRIS group) and flag 0x200000 (no collisions) are nothing
prop_door_rotatinganyThe first .phy solid (the door sets SOLID_VPHYSICS itself)
prop_ragdollanyLeft out, see below

A model’s physics is the first model it includes ($includemodel) that has a .phy, else its own (CMDLCache::UnserializeVCollide). The engine tests a model only against a mask that shares a bit with its $contents (enginetrace.cpp): a model whose contents a player is not stopped by is nothing. The engine does not compare a .phy’s checksum with its model’s, so a checksum that differs, as in a replacement addon’s, is not a reason to drop it. Every solid of a model is read in its bone’s frame, so a ragdoll’s pieces sit at their bones at the first frame of the first sequence.

A prop_physics is a live body at its origin and the engine drops it onto whatever is under it the moment the map spawns it, so mappers hang bottles a hand above a pile and let gravity do the rest. The lump holds the hung position, and DH has no simulation to fall with, so left alone the props float. The import lowers each falling prop straight down, keeping its angles, until its collision meets the first of: a world or solid brush entity hull (a player clip brush holds nothing up), the hull of a static prop or other prop that has a physics object (a bounding box stops a trace and holds nothing), a prop already at rest, or the top of a water or slime brush, where the engine floats it. Props settle lowest first, so stacks build up. A debris prop (flag 4) collides with the world and the static props and with no other physics prop, so it passes through the dynamic ones. These stand where the lump puts them:

  • a prop asleep (flag 1), motion-disabled (flag 8), waiting on damagetoenablemotion or forcetoenablemotion, with a parentname, or named by a phys_* constraint’s attach1 or attach2;
  • a prop with nothing under it, or no physics shape (flag 0x200000, no .phy), counted as unsupported or left alone.

The sweep is between convex hulls, so it is exact for the pieces the import already builds and approximate against a model whose first solid is not the whole of what the engine collides with; a prop is never tipped over or rotated to rest. The report counts settledProps, the floatingProps that hung more than 5 cm, floatingOnWaterProps, unsupportedProps and the total and longest drop, and every entity prop’s dropMeters and restsOn.

Ragdolls. Source’s prop_ragdoll does collide with a player (collision group NONE, the group of everything, unless it has the debris flag), but it is a simulated body that settles where it falls, so the import has no resting pose to build its collision from and leaves it out. The report counts them.

The report counts models by what their .phy gave (Pieces, NoFile, Unreadable), props that collide as physics or as a box, props the map or the game’s rules make non-solid, props the game gives no collision either, and ragdolls, and lists the props of each class by solid, physics outcome and result (collisionCases).

What is not carried. Collision of ragdolls and of a model’s bone_followers key values, physics (a prop_physics is dropped to rest once at import and stands still after, and DH’s physicsProp is a box, so a model’s mass and shape have nowhere to go yet), animation past the spawn pose, flexes, body groups other than the default, modelscale, fade distances and per-prop color. Lighting. Nothing more is needed for a prop to be lit. An instance’s meshes are not on the lightmap, so the map’s light probe group lights them by position, and the map’s lamps and sun reach them as they reach any other surface. The offscreen renderer does not draw a map’s world placements (they stand through the logic runtime), so this is read from the engine’s code; the backdrop’s props are the exception, drawn by both hosts from the map, and those are photographed below.

Beside the map the importer writes maps/<map>.import-report.json: what was imported, left out and warned about, counts, the calibration used, the fog and sky camera the map declares, every file written. It is never compiled into the pallet. The console prints the same summary, then a Timings: line with the total and each step slowest first: mount (the game’s file system), openBsp, pakfile (the map’s embedded files), and the import steps. The timings are printed and left out of the file.

The import’s heavy loops run on every core: the textures, the models, and each reflection probe’s prefilter. On a 16-core machine, gm_poop_city with its content packs (6,965 models, 12,660 textures) imports in 156 s where it took 766 s one at a time, and again in 22 s; d1_trainstation_02 in 10 s where it took 18 s.

Importing the same map again writes the same bytes. Every file the import generates, the report and the map’s manifest included, is written only when its content differs from what is on disk, because a rewritten file carries a new write time and dh build reads a new write time as a pallet that changed. The map’s manifest is written once with every dependency its materials named. Re-importing a map that has not changed therefore leaves its pallet, and the base pallets its materials went into, alone, and the next build skips them.

PlatformImporter
Windows desktop✅
Linux and macOS desktop❔

The importer is portable C#. Only Windows has run it.

Every failure carries a stable code and a short sentence:

CodeMeaning
workspace-missingThe workspace folder doesn’t exist
avatar-not-foundNo compiled pallet has the avatar
avatar-unreadableThe avatar’s pallet couldn’t be read
not-humanoidThe avatar has no humanoid rig
no-hip-boneThe rig maps no hip bone
missing-bonesThe rig lacks both upper arms, which set the facing
too-many-bonesOver 128 bones even after merging every optional one
game-missingThe game folder, or its player animations, can’t be found
studiomdl-missingThe game’s studiomdl.exe isn’t where it should be (on Linux, DH_STUDIOMDL names none)
studiomdl-failedstudiomdl stopped with an error (its first error line is quoted)
studiomdl-timeoutstudiomdl ran past ten minutes
wine-missingOff Windows, no Proton or Wine was found to run studiomdl with
wine-failedWine couldn’t set up the prefix studiomdl runs in
hands-no-meshNo mesh is on the arms, so there are no hands (the export still succeeds)
hands-failedstudiomdl failed on the hands (the export still succeeds)
npc-failedstudiomdl failed on the NPC model (the export still succeeds)
output-failedA file couldn’t be written
canceledThe export was canceled
export-failedAnything else; the log has the details