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.
What an Export Does
Section titled “What an Export Does”An export runs in seven steps, and every host reports them by these names:
- 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.
- Fit to Valve’s skeleton. The avatar’s humanoid rig is mapped onto
ValveBiped, and the SMD, QC and materials are written. - Compile the model. Garry’s Mod’s own Windows
studiomdl.execompiles 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). - Compile the hands. The viewmodel hands are compiled the same way.
- Compile the NPC. The NPC model is compiled the same way.
- Pack the .gma. The models and their materials are packed into one addon file.
- 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 Fit
Section titled “The Fit”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 ownm_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 toValveBiped’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
LeftEyeandRightEyeare 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
eyesattachment 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, theeyesof 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.
Materials
Section titled “Materials”Each dh.material becomes a VertexLitGeneric VMT (UnlitGeneric for the unlit shader):
| DigitalHeaven | Source |
|---|---|
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) |
roughness | Phong: $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.
Spring Bones
Section titled “Spring Bones”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 Hz | the 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 / polar | pitch_constraint and yaw_constraint of the limit’s width / pitch with yaw locked / pitch and yaw |
| no limit | pitch_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.
Viewmodel Hands
Section titled “Viewmodel Hands”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 includesweapons/c_arms_animations.mdl, the way stock hands do. It has one skin and one bodypart, so it takes skin0and 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.
NPC Model
Section titled “NPC Model”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_citizenwith theHostilekeyvalue, 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.
Eye Look and Blinking
Section titled “Eye Look and Blinking”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
eyelidsnames (theblinkshapes,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’sdh.eyeslets run are written: withblink.enabled: falsethere is no blink at all, and withlook.enabled: falseno lid follow. Each is declareddecay 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>.blinkis 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 raisesdh_native_offand drivesdh_blink_<i>,dh_look_upanddh_look_downitself. - Eye bones. The rig’s eye bones are kept, and the limit poses of its
eyeRotationLimitsare 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:
| Choice | What the mesh gets |
|---|---|
on | It takes blood itself, as a stock model does |
off | Its materials become $nodecal copies: no blood lands on it |
proxy | Where 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:
offwhen its largest connected piece is at mostbloodTinyMeshof 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.proxyfor the triangles inside any splat overbloodSplatBudget. 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 mostbloodProxySplat, and further, in proportion with the other copies, while the model’s worst splat still passes the budget.onotherwise.
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.
Settings
Section titled “Settings”These settings shape an export:
| Key | Values (default first) | Meaning |
|---|---|---|
proportions | own, hl2 | hl2 leaves out the proportions layer, so Valve’s animations stretch the avatar to a stock player’s lengths |
fit | hips, eyes | eyes 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 |
springBones | true, false | Whether spring chains become jigglebones |
hands | true, false | Whether the export also makes the viewmodel hands |
npcs | true, false | Whether the export also makes the NPC model |
blood | an object of mesh name to on, off or proxy | Overrides 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 |
bloodSplatBudget | 10240 | The largest splat, in indices, a model may keep before a region gets a proxy |
bloodProxySplat | 10240 | The largest splat a proxy may make on its own: its first decimation target |
bloodTinyMesh | 0.1 | A mesh whose largest piece is at most this share of the model’s height takes no blood |
jiggleStableFrameRate | 20 | The 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) |
jiggleSafetyStop | 80 | The 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:
- The built-in default.
- The workspace, in
dh-config.jsonc. - The avatar’s own pallet.
- The user’s force: the addon’s settings, or
dh export sourceflags.
Workspace
Section titled “Workspace”{ "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.
Per Avatar
Section titled “Per Avatar”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.
{ "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.
What the Model Carries
Section titled “What the Model Carries”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:
| Key | Holds |
|---|---|
eyes | The avatar’s resolved eye behavior, as the JSON of the dh.eyes that states it, in base64 |
eyeLimits | Up, down, left, right, inner and outer, in degrees |
eyeLeft, eyeRight | The exported eye bones |
eyePoseLeft, eyePoseRight | Each 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, eyeBlinkEyes | The flex controller of each blink shape in order (- for one that didn’t resolve), and the eye it closes |
eyeBlinkOff | The controller that turns Source’s own blink off (dh_native_off) |
eyeLookUp, eyeLookDown | The lid follow controllers |
Command Line
Section titled “Command Line”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--gamedefaults to the Steam install of Garry’s Mod.--outdefaults to the game’sgarrysmod/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.
An exporter older than its pallets
Section titled “An exporter older than its pallets”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.
Finding studiomdl
Section titled “Finding studiomdl”Every host finds the game and its compiler through one resolver, SourceGameInstall:
- The game is the first
steamapps/common/GarrysMod/garrysmodin a Steam library: the Steam root (Program Files (x86)/Steamon Windows;~/.local/share/Steam,~/.steam/steamor the Flatpak’s on Linux), then every library itslibraryfolders.vdflists. - studiomdl is
DH_STUDIOMDLwhen set, then the game’sbin/win64/studiomdl.exe, then the 32-bit branch’sbin/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 astudiomdl.exebeside the DLLs it loads:tier0,vstdlib,steam_api64,filesystem_stdio,materialsystem,shaderapiempty,studiorender,mdllibandvphysics(about 8.4 MB, all from the x86-64 branch’sbin/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_WINEwhen set, else Proton from the game’s own Steam library or any other (the newest numbered version first, then Experimental), elsewineonPATH. Proton’s own libraries go onLD_LIBRARY_PATHin 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_HOMEwhen set;DH_WINEPREFIXoverrides it), so a~/.wineis never touched. The first export creates it withwineboot -u. - Headless. Wine runs with no
DISPLAYorWAYLAND_DISPLAY, no Mono or Gecko prompts (WINEDLLOVERRIDES=mscoree,mshtml=), no debug output and none of the game’sLD_PRELOAD. - Paths reach it through Wine’s
Z:drive, including the scratchgameinfo.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
.mdldiffered 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.
Reading Source Content
Section titled “Reading Source Content”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.
| Reader | Reads |
|---|---|
BspFile | Compiled maps, VBSP versions 19 to 22, with typed views of the lumps |
PakfileReader | The zip inside a BSP’s pakfile lump (stored and deflate) |
KeyValuesReader | VMT, gameinfo.txt, mount.cfg, mountdepots.txt, libraryfolders.vdf, appmanifest_*.acf |
VtfImage | VTF 7.0 to 7.5: every frame, cube face and mip |
GmaArchive | Workshop addons (.gma), and legacy *_legacy.bin items |
VpkReader | VPK v1 and v2 sets |
SourceFileSystem | An ordered stack of folders, VPKs, GMAs and a map’s pakfile |
SourceMapCatalog | The 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 asttt_severeduse. The reader tells them apart from the file, andUsesLeftFourDeadHeadersays which it found. - Compressed lumps. TF2 stores lumps as Valve’s
LZMAheader around a raw LZMA stream. They are decoded on read byLzmaDecoder, 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’sfourCCcounts as its decoded size only when its bytes start with theLZMAheader. 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 (
FACESandFACES_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.
OpenPakfilereads the embedded zip in place. A method other than stored and deflate is listed but refuses to open, naming the method number. - Partition tree.
WorldTreeandModelTreehold 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 whendot(normal, p) - dist >= 0), the reachable node and leaf counts, the deepest path, andFindLeaf. 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.
Mount stack
Section titled “Mount stack”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.txtSearchPathsin order, honoring|gameinfo_path|and|all_source_engine_paths|,dir/*wildcards and.vpksets. A folder also mounts every*_dir.vpkinside it. Entries that only name a binary folder are skipped. - Garry’s Mod. The search paths,
cfg/mount.cfg, and each gamecfg/mountdepots.txtenables that is installed in any Steam library. Its addons are not wheregameinfo.txtputs 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.hl2for thehl2folders andsourceengine/hl2_*packs,com.valvesoftware.cstrikeforcontent_cstrike, andcom.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:
- The map’s own pakfile.
- The map’s own Workshop item, when the map is a Workshop map (
SourceFileSystem.Promote, for the import only). - 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).
- Content addons, fill only (
SourceFileSystem.AddAddon): every installed Workshop item ofsteamapps/workshop/content/4000(a legacy*_legacy.binis read fromgarrysmod/cache/workshopwhen that copy exists, else decoded) and every looseaddons/*folder.cfg/addonnomount.txtis 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.
Why the import ignores your addon list
Section titled “Why the import ignores your addon list”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.
Map discovery
Section titled “Map discovery”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.
Importing Source Materials
Section titled “Importing Source Materials”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:
| Outcome | Meaning |
|---|---|
Material | A barcode, and the CubemapOrigin when the name is a cubemap patch |
DropFace | A tools material (nodraw, clip, trigger, skip, hint, invisible, …): the face is not drawn |
Sky | A sky material: the map importer handles the sky |
Unresolved | No 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.
Where a material goes
Section titled “Where a material goes”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.
| File | Barcode or path |
|---|---|
| Material | <pallet>:materials/<name>.dh-mat, the lowercased Valve path with forward slashes |
| Texture | textures/<vtf path>.png; a normal map is .normal.png |
| Derived texture | textures/<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.
Patches
Section titled “Patches”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.
The VMT to dh.material map
Section titled “The VMT to dh.material map”| Source | DigitalHeaven | |
|---|---|---|
LightmappedGeneric, VertexLitGeneric, WorldVertexTransition | lit shader | ✅ |
UnlitGeneric | unlit shader | ✅ |
$basetexture, $color, $alpha | baseColor, tint | ✅ |
$bumpmap | normal, green flipped (Source is -Y, DH +Y) | ✅ |
$translucent, $vertexalpha | alphaMode: blend | ✅ |
$additive | alphaMode: blend, blendMode: additive, the texture as it is (see Additive and multiplying surfaces) | ✅ |
The Modulate shader; with $mod2x; DecalModulate | blendMode: multiply; mod2x; mod2x, unlit, the texture as it is | ✅ |
$decal | depthBias: 1 | ✅ |
UnlitTwoTexture’s $texture2, $texture2transform | The 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 target | Invisible: an unlit, fully transparent material, and a world face that uses it is left out. Named in the report’s hidden | 🟡 |
$alphatest, $alphatestreference | alphaMode: mask, alphaCutoff | ✅ |
| None of those | alphaMode: opaque, written explicitly. The base texture’s alpha is a mask here ($basealphaenvmapmask, $selfillum, $blendtintbybasealpha, phong masks), never opacity | ✅ |
$nocull | renderFace: both | ✅ |
$selfillum, $selfillummask, $selfillumtint | emissive (the base color through the mask), emissiveColor | ✅ |
$detail, $detailscale, $detailblendfactor, $detailtint | detail block (own uvScale, strength, tint) | ✅ |
WorldVertexTransition’s $basetexture2, $bumpmap2, $basetexturetransform2 | detail 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 | ✅ |
$blendmodulatetexture | not carried: the layers cross-fade by the alpha alone (blendModulate) | 🟡 |
$detailblendmode 0 and 7, 1 and 5 and 6, 2 and 3, 8 | mulx2, 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, $envmaplightscale | An 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 | 🟡 |
$surfaceprop | surface.preset (Source’s names fold onto DH’s fourteen presets; concrete is stone) | ✅ |
$basetexturetransform | uvScale 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 $basetexturetransform | uvScroll, the rate along the angle (see Motion) | ✅ |
Proxies: AnimatedTexture on $basetexture | flipbook over an atlas of the VTF’s frames (see Motion) | ✅ |
Proxies: TextureScroll on $texture2transform | uvScroll 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 transform | not carried | ⌠|
Cable, Eyes and other shaders | the 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.
Blended displacements
Section titled “Blended displacements”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.
Additive and multiplying surfaces
Section titled “Additive and multiplying surfaces”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, ONEunless 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.ModulateandDecalModulatefade 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’smul(mulx2) does to the color, at the second texture’s own$texture2transformscale. 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 itsuvScroll, and the first is the detail layer. The detail layer adds no alpha, so on an additive$translucentsurface 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 thesecondTexturewarning. A material whose$detailalready holds the layer drops the second texture, with the same warning. $decalis adepthBiasof one step, so it wins against the surface it lies on.info_overlayentities 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.
| Class | Does |
|---|---|
SpriteCard and Sprite | Invisible: a particle card’s color and alpha come from the particle, and its frames from the system |
An additive or modulate surface with $vertexalpha | Invisible: 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 missing | Invisible: its blend would lay a flat color over the frame (dev/dev_combinemonitor_3’s _rt_Camera) |
$mod2x on any other shader | Not 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 | $additive | Modulate shader | $mod2x | SpriteCard and Sprite |
|---|---|---|---|---|
squidgame_facility | 2 (lcd_timer2024_additive, glasswindow_frosted_003) | 0 | 5 (UnlitTwoTexture CCTV screens: not a blend) | 0 |
gm_bigcity_improved | 3 (glass_clear01 and two window copies) | 0 | 0 | 0 |
d1_canals_01 | 2 (combine_fenceglow, comshieldwall2) | 0 | 0 | 0 |
d1_trainstation_02 | 5 (vol_lightmask02, comshieldwall and comshieldwall2, two dev_combinemonitors) | 0 | 0 | 0 |
gm_prison | 1 (vol_lightmask02) | 0 | 0 | 0 |
d1_trainstation_01 | 3, of which dev_combinemonitor_3 and _b are invisible (a render target) | 0 | 0 | 0 |
| The other eight | 0 | 0 | 0 | 0 |
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.
Motion
Section titled “Motion”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:
| Proxy | Becomes |
|---|---|
TextureScroll with textureScrollVar $basetexturetransform | uvScroll = 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 $basetexture | flipbook: 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 move | uvScroll 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.
Tools and sky
Section titled “Tools and sky”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.
Textures
Section titled “Textures”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.
Measured
Section titled “Measured”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.
Importing Source maps
Section titled “Importing Source maps”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.
dh import source-map --list --game gmoddh import source-map gm_construct --game gmod --out Sourcedh import source-map de_dust2 --game cstrike --out Sourcedh import source-map "D:mapsmy_map.bsp" --out Source--gametakes a platform ID or alias (gmod,hl2,cstrike,com.valvesoftware.tfand so on) and defaults togmod. It picks the mount stack that finds the map by name and the spawn classes that read it.--listprints 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.
--outis the folder the pallets are written under (the workspace’sSourcefolder to build them), one folder per pallet (ImportedPalletLayout.FolderOf). The map’s pallet and the base pallets its materials are generated into (com.valvesoftware.hl2and 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--outat 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 ind1_canals_01. The report’scontentAddonsnames 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 examplecontent addons filled 42002 files the map and the game lack: 106449281 "Minecraft model pack" (2), .... - The library entry point is
SourceMapImporter.ImportinDigitalHeaven.Platforms.Source, which Studio’s Import page runs throughdh. The command is a thin wrapper that prints the report. --progressprints 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), orprops: 191 placed ...in the plain log. The importer reports throughSourceMapImportOptions.Progressand.Summary;Logkeeps printing the bare step names. The line format is in the CLI reference.- The pallet states which importer made it. Its manifest’s
metadataholdsimporter(source-map),importerVersionandgame(the platform ID the map was imported through, sod1_canals_01imported through Garry’s Mod sayscom.facepunch.gmod) andoriginGame(the game that ships the map: the owner of the layer it was read from, so that same map sayscom.valvesoftware.hl2, while a Workshop or loose map says the importing game) andsource(ImporterVersions.SourceKey:stockfor a map a game ships,workshopfor a Workshop item’s,localfor 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 isImporterVersions.SourceMapin Core, derived from the stage tableImporterStageVersions.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>.
What carries over
Section titled “What carries over”| Source | Becomes | State |
|---|---|---|
| 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 | ✅ |
| Displacements | Tessellated 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 faces | Left 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 brushes | One 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 collision | vbsp keeps no brush for a displacement, so each is covered by slabs and prisms under its grid | 🟡 |
| Player spawns | One object carrying a dh.spawnPoint per spawn entity of the game’s classes, at the feet, yaw kept and pitch negated | ✅ |
light_environment | The map’s sun (direction, color, intensity) | ✅ |
| 2D sky | The six VTFs resampled to one equirectangular .hdr (an HDR cube or 8-bit pictures decoded with gamma 2.2) | ✅ |
| GMod’s painted sky | A 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) | 🟡 |
| Tonemap | The map asks for render.tonemap: none, Source’s own output curve (see Matching the display) | ✅ |
| Lightmaps, HDR when the map has them | The imported .dh-lightmap, origin: "imported", with the sky’s fill and the bounce baked in | ✅ |
env_cubemap | An 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 | ✅ |
| Materials | Through 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 collision | Each 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
waterBrushesWithoutSurfaceand 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).
| Source | Becomes | State |
|---|---|---|
func_door | An 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 state | A 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-return | A 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_movelinear | A 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_rotating | The 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 doors | A 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_rotating | A 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 doors | CBasePropDoor::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_rotating | A 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_button | An 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 -1 | It 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_multiple | An 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_once | The 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 filters | Only 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_teleport | One 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 flags | Flag 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 destinations | The 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 | ✅ |
landmark | dh.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_relative | A 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_teleport | An 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_auto | An 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_relay | A 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_timer | A <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 | 🟡 |
parentname | A 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). !selfand!callerare the entity firing.!activator,!playerand 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,ToggleandUse(and a prop door’sOpenAwayFrom), a relay’sTrigger,EnableandDisable, a timer’sEnable,DisableandFireTimer, a trigger’sEnable,DisableandToggle. A parameter is ignored. - A
timesToFireother 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.
| Map | Movers | Buttons | Timers | Relays | Triggers | Map starts | Connections mapped of total |
|---|---|---|---|---|---|---|---|
gm_prison | 153 | 10 | 1 | 0 | 0 | 0 | 55 of 80 |
gm_mallparking | 22 | 5 | 0 | 0 | 2 | 0 | 6 of 13 |
squidgame_facility | 108 | 58 | 2 | 12 | 17 | 3 | 56 of 858 |
d1_trainstation_02 | 10 | 2 | 4 | 14 | 40 | 4 | 13 of 307 |
gm_fork | 0 | 4 | 0 | 0 | 0 | 1 | 0 of 30 |
gm_bigcity_improved | 2 | 93 | 0 | 0 | 0 | 1 | 0 of 266 |
gm_construct | 0 | 1 | 0 | 0 | 0 | 0 | 0 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.
The 3D skybox
Section titled “The 3D skybox”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.
SourceSkyAreafinds the areasky_camerastands 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’sbackdrop.partsnames 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
folderrecord namedbackdropthatbackdrop.foldernames, so they never stand in the world. - The camera.
sky_camera’s origin throughValveWorldSpaceis the anchor, and itsscale(16 when absent) is the scale. - Its fog.
sky_camera’sfogenable,fogcolor,fogstart,fogendandfogmaxdensitybecomebackdrop.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’senv_fog_controlleris 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.
Reflections
Section titled “Reflections”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_cubemapthat something reflects becomes an object carrying adh.reflectionProbe, namedcubemap<index>, withprojection: "none"and itspositionat the cubemap’s origin. It has noscale,capturePoint,boxProjectionorimportance, 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.rendererwhosereflectionProbenames the cubemap nearest the point Source lights it from. That point is the origin of the entity itslightingoriginnames (aninfo_lighting, the first entity with thattargetname). 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
envmapblock carries what the shader does after the lookup:Field Source Notes 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. $envmapmaskis its own texture, read as data. A base-alpha mask is{ "from": "baseAlpha" }, and the material is markedalphaMode: opaquewhen 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 intotextures/<material>.envmask.pngand bound as the mask texturefresnel$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), thenlerp(gray, c, saturation). The skin shader has neitherlightScale$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.
The lightmap
Section titled “The lightmap”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 asGetBumpNormalsdoes 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_severedand its kind store an unused extra block per style;BspFile.LightmapExtraBlocksfinds 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 probe group and the lamps
Section titled “The probe group and the lamps”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.
Visibility
Section titled “Visibility”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.
Matching the display
Section titled “Matching the display”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.
Models
Section titled “Models”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.
| File | What it is |
|---|---|
meshes/models/<path>.glb | The geometry, in DH’s meters and axes, one root node named for the model |
objects/models/<path>.dh-obj | The 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-obj | A 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
skeletonnode (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 theiranglesa 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. Aprop_door_rotatingthat 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
$cdmaterialsfolders in order, and the first folder that holds the VMT wins. The material importer turns it into adh.materialin 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):
| Prop | solid | Collides as |
|---|---|---|
prop_static | 6 | The 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_static | 2 | A 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_static | 0, or any other | Nothing (an unlisted value is a “bogus SOLID_ flag” and becomes 0) |
prop_dynamic, prop_dynamic_override | 6 | A 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_override | 2 | The 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_override | 3 | The hull box turned with the entity |
prop_dynamic, prop_dynamic_override | 0, 4, 5, or no solid key | Nothing. 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* | any | The 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_rotating | any | The first .phy solid (the door sets SOLID_VPHYSICS itself) |
prop_ragdoll | any | Left 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.
Settling physics props
Section titled “Settling physics props”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
damagetoenablemotionorforcetoenablemotion, with aparentname, or named by aphys_*constraint’sattach1orattach2; - 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.
The report
Section titled “The report”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.
| Platform | Importer |
|---|---|
| Windows desktop | ✅ |
| Linux and macOS desktop | â” |
The importer is portable C#. Only Windows has run it.
Errors
Section titled “Errors”Every failure carries a stable code and a short sentence:
| Code | Meaning |
|---|---|
workspace-missing | The workspace folder doesn’t exist |
avatar-not-found | No compiled pallet has the avatar |
avatar-unreadable | The avatar’s pallet couldn’t be read |
not-humanoid | The avatar has no humanoid rig |
no-hip-bone | The rig maps no hip bone |
missing-bones | The rig lacks both upper arms, which set the facing |
too-many-bones | Over 128 bones even after merging every optional one |
game-missing | The game folder, or its player animations, can’t be found |
studiomdl-missing | The game’s studiomdl.exe isn’t where it should be (on Linux, DH_STUDIOMDL names none) |
studiomdl-failed | studiomdl stopped with an error (its first error line is quoted) |
studiomdl-timeout | studiomdl ran past ten minutes |
wine-missing | Off Windows, no Proton or Wine was found to run studiomdl with |
wine-failed | Wine couldn’t set up the prefix studiomdl runs in |
hands-no-mesh | No mesh is on the arms, so there are no hands (the export still succeeds) |
hands-failed | studiomdl failed on the hands (the export still succeeds) |
npc-failed | studiomdl failed on the NPC model (the export still succeeds) |
output-failed | A file couldn’t be written |
canceled | The export was canceled |
export-failed | Anything else; the log has the details |