Skip to content

Source 2 (import)

dh import source2-map reads a built Source 2 map from your install and writes a DigitalHeaven map pallet. Nothing of Valve’s, Facepunch’s or the map author’s is ever shipped: DigitalHeaven ships the recipe, your install is the ingredient, and the pallet is generated on your machine from your own copy.

Source 2 has no compile path here. A map is imported as the game shipped it, a .vpk of compiled resources, never from a .vmap. The import is also wave by wave: this page says what carries over today and what is planned.

Powered by Source 2 Viewer (ValveResourceFormat). The importer reads Valve’s formats through that library (MIT) and ports none of its code.

The importer is an optional module. dh holds no reference to it, so dh build, the engine, Content and Core never load it, and everything keeps working when it is absent. Without it, dh import source2-map says how to install it and exits with code 1.

Publish it beside the tool from a checkout:

Terminal window
dotnet publish Platforms/DigitalHeaven.Platforms.Source2 -c Release -o <dh folder>/modules/source2

The places are named once, in Core’s ImporterModuleLocation, which both dh and Studio read; Studio’s Import page shows when the module is missing and publishes it (-c Debug) from the checkout the workspace’s enginePath sits in.

dh looks for DigitalHeaven.Platforms.Source2.dll in modules/source2 beside itself, then beside itself directly. DH_SOURCE2_MODULE names another folder (or the assembly) and replaces both places. The module loads into its own context with the libraries it brings, so ValveResourceFormat and what that needs never touch dh’s own. Only plain types cross: strings, a dictionary and an exit code.

The library is pinned to one exact version (ValveResourceFormat 20.0.6980). Its releases change the API, so every call into it sits behind a small seam in the Vrf folder of DigitalHeaven.Platforms.Source2 and the rest of the project never names a library type.

Terminal window
dh import source2-map --list --game cs2
dh import source2-map de_ancient --game cs2 --out Source
dh import source2-map dl_midtown --game deadlock --out Source
dh import source2-map dev/preview_flat --game sbox --out Source
dh import source2-map facepunch.construct --game sbox --out Source
dh import source2-map facepunch.construct --game sbox --revision 2026-07-02 --out Source
dh import source2-map 3437809122 --game cs2 --out Source
dh import source2-map "D:\maps\my_map.vpk" --game cs2 --out Source
  • --game takes a platform ID or alias: cs2 (com.valvesoftware.cs2), deadlock (com.valvesoftware.deadlock) or sbox (com.facepunch.sbox). It defaults to cs2.
  • --list prints the maps the game’s maps folders offer, leaving out the UI scenes, prefabs and templates that have no world, then the map of each installed Workshop item, then for s&box the map of each cached package. Each line is the map’s name, its size in megabytes, stock, workshop <id> "<title>" or package <org.ident> "<title>", and the pack it lives in: de_cache 659.3 MB workshop 3437809122 "Cache" ...\730\3437809122\3437809122_dir.vpk. The last line counts both: 72 maps in Counter-Strike 2: 48 stock, 24 from the Workshop.
  • A name is found under the game’s search folders first, then among the Workshop maps; a Workshop item’s id picks its map; an s&box package’s ident or its map’s name picks its package, at the newest cached revision unless --revision says otherwise; a path reads that .vpk alone, or a cached s&box package of any file name, and mounts the game around it.
  • --out is the folder the pallets are written under, one folder per pallet (ImportedPalletLayout.FolderOf, the layout the Source importer uses).
  • --progress prints progress 5/10 materials as each step starts and a count with the item last worked on, progress 8/10 materials 120/265 "materials/de_dust/hr_dust/hr_dust_sand_02", about ten times a second while a step loops: the world nodes, the placements whose models the geometry step reads, the entity lumps, the collision shapes, the materials (converted several at a time, then their variants, then settled in placement order), the pieces written and the slots bound. Each step then prints summary materials 272 converted, 0 untextured, 4 hidden, 799 textures (0 from the cache, 0 failed) (1 m 29 s), or materials: 272 converted ... in the plain log; the line format is in the CLI reference. Steps are counted from finding the map: findMap, mount, findWorld, layout, geometry, entities, collision, materials, write, map.
  • The map’s pallet manifest carries metadata.importerVersion (ImporterVersions.Source2Map in Core), metadata.game, and metadata.originGame, the same game, since a Source 2 map is always read from the game that ships it, even a Workshop map. A pallet from before the stamp has none of them, which Studio reads as stale.
  • The version is derived from the stage table ImporterStageVersions.Source2Map: its base plus every stage. When an output changes, bump the stage that makes it, never the total. The re-import Studio then offers redoes only that stage: the lightmap, the light probe group and the reflection cook are kept while their key matches (stages.dh-import at the pallet’s root, What an import reuses). The lightmap’s key is the joined pieces, each piece’s lightmap coordinates and the irradiance atlases’ CRCs from the pack’s directory; the probe group’s is the probe volumes’ entities and their textures’ CRCs; the cook’s is the joined pieces and the cubemaps’ entities and their arrays’ CRCs. Each bake’s plan still runs every time (the atlases are found, the cubemaps chosen from their entities), and the textures are only decoded when a bake runs. A change to the Content code a bake calls bumps its stage too.
  • It also carries metadata.source (ImporterVersions.SourceKey): stock for a map the game ships, workshop for a Workshop item’s, package for a cloud package’s map in the game’s package cache, local for a loose .vpk that is not one of the game’s (ImportSources in Core). The Source importer writes the same key with the same values, so the engine’s map picker and Studio can split a game’s maps into stock and Workshop without reading the pallet id. ImportOrigins.SourceOf reads it, and for a pallet from before the key, takes the Workshop namespace as workshop and anything else as stock.

The pallet ID follows the Source convention: <game id>.maps.<map name>, such as com.valvesoftware.cs2.maps.de_ancient, and a Workshop map is its item’s own pallet, com.steamcommunity.workshop.<id>, and an s&box package’s map is com.facepunch.sbox.<org>.<ident>. ImportedPalletLayout.MapId and ImportOrigins.WorkshopPalletId in Core build them for both importers.

CS2’s Workshop items are found through SteamLibrary.WorkshopItems in Core, the discovery the Source importer reads Garry’s Mod’s with: every folder under steamapps/workshop/content/730 of the install’s own Steam library and then every other library. An item folder holds one pack (<id>.vpk, or <id>_dir.vpk with numbered archives) and the publish_data.txt the Workshop tools write.

  • The map. An item’s pack stores its map’s pack whole, maps/<map>.vpk, beside the materials and models the map uses. The importer reads that inner pack in place, never extracting it. One item is one map: when the pack holds several, the map is the largest one that is not under a prefabs folder and not named as a sub-map (skybox, _sky, _prefab), so an item’s 3D skybox or prefab map is not offered on its own.
  • The name. The pallet and the map are named after what the item calls itself, the title in its publish_data.txt (Cache, Dust 2 Night); an item that states no title is named after its map. The map’s file keeps the map’s own name (maps/de_cache.dh-map).
  • The mount. The map’s pack comes first and the item’s pack right behind it, both belonging to the map’s pallet; then the game’s search folders; then every other installed CS2 Workshop item, in a fill tier behind everything. A fill layer only serves a path nothing in front holds and never replaces the game’s content, the same rule as Garry’s Mod’s addons. What a fill layer serves is written into the pallet that asked for it, so a game’s pallet never points into a Workshop item’s. The report’s filledFrom counts what each fill pack served, and a warning names them. A stock map’s import mounts no Workshop item.

Measured on this machine, all 24 installed CS2 Workshop items offer a map. cs_construct (item 3085087704, titled gm_construct) converts every material, its csgo_water pond a water block. site48 (item 3255945975, a 2 GB item titled SCP Site48 [Демонстрация]) imports in about a minute: 2.1 million triangles in 7 pieces, 2,878 hull colliders, 223 of 237 materials, the 14 others its csgo_character statues, which have no DH counterpart yet. liminal (item 3242296546) converts 14 of 15.

The Steam libraries come from Core’s SteamLibrary, the same discovery every other importer uses. Each game is mounted as a layered file system, the first layer that holds a path serving it:

  1. the map’s own pack, when one is open (it holds the map’s lightmaps, world nodes and entity lumps, and the models made for it)
  2. the map’s addon pack when the game ships one: CS2 keeps the materials of the maps it ships as addons (cs_shelter, de_boulder, de_debris, de_eldorado, de_fachwerk, de_poseidon) in game/csgo_community_addons/<map>/<map>_dir.vpk (the gameinfo.gi OfficialAddonRoot), while the map’s own pack only names them. It belongs to the map’s pallet like the map’s own pack. For a Workshop map, the item’s pack takes this place
  3. each of the game’s search folders from its gameinfo.gi, as loose files and then as the pak01_dir.vpk inside: game/csgo then game/core for CS2, game/citadel then game/core for Deadlock, core for s&box
  4. for a Workshop map only, every other installed Workshop item of the game, as the fill tier
  5. the game’s package cache, also in the fill tier, for any map: s&box’s gamecache (Source2Game.PackCaches), read in place and never copied or renamed. See s&box’s cache

s&box keeps what it downloads in gamecache, 40,000 files named <md5>.<crc>.cache, and Construct’s materials, models and textures are in none of the game’s other folders. Two kinds of file are in it, and the importer mounts both, read-only and in place:

  • Single-file VPKs (the maps’ own packs and a handful of bundles; 17 here). A file is a pack by its first four bytes (Source2Packs.IsVpk), whatever it is called, and each is a fill-tier pack the way a Workshop item’s is.
  • Loose resources, each stored under the MD5 of the path that asks for it: materials/cables/cable_a.vmat_c is fc2f1b10c7234357c4cf1989e75448f9.<crc>.cache. Source2ContentCacheLayer indexes the folder once by that hash, answers any path by hashing it, and serves the newest file when a path is stored in several versions. Its names cannot be reversed, so it can answer a path but not list one.

A map pack in the cache is a package: it is listed by name, imported by its package’s ident or by the path of its cache file (dh import source2-map "<install>\gamecache\ff0adbb7….333430fc5e901cd1.cache" --game sbox), and its metadata.source is package.

dh import source2-map --list --game sbox lists the install’s own maps as stock, then each map package the cache holds as package <org.ident> "<title>":

construct_25 610.2 MB package facepunch.construct "Construct" ...\gamecache\ff0adbb7….333430fc5e901cd1.cache
revision 333430fc5e901cd1 (2026-10-02) newest
revision 4dafb76f227580c3 (2026-07-02)
edinburgh/edinburgh 37.6 MB package unplaced "edinburgh" ...\gamecache\193d6b66….b3a9e400665dff87.cache
29 maps in s&box: 20 stock, 0 from the Workshop, 9 from packages.

A package is imported by its full ident (facepunch.construct), by its ident alone when only one package has it (construct), by its map’s name (construct_25), or by the path of one of its cache files. Only maps are offered: a pack whose world is a sub-map (construct_25_sky, edinburgh_skybox) or a prefab (maps/prefabs/...) is not a map of its own.

Finding the packs. SboxPackages reads the cache folder once (Source2Packs.IsVpk on each file, then the world names in each pack’s tree) and keeps the scan in the workspace’s import cache, keyed by the folder’s file names, sizes and write times, so a list after the first costs a directory listing. Files with the same MD5 and different CRCs are revisions of one pack: s&box downloads a new file when a package updates and leaves the old one. The newest by write time is the default.

What the disk says, and how far to trust it. The cache names a file <md5>.<crc>.cache, the MD5 of the file’s path in the package (maps/construct_25.vpk) and the CRC the package’s manifest states for that version of it. Checked against Construct’s manifest, md5("maps/construct_25.vpk") is the file name’s MD5 and the manifest’s crc is its CRC.

FactWhere it comes fromReliable
The map’s namethe pack’s maps/<name>/world.vwrld_c✅
The pack’s path in the packagean MD5 of maps/<name>.vpk that equals the file’s✅ when it matches (PackPath; null for a pack no such path hashes to)
The revisionthe CRC in the file name, the manifest’s crc for that pack✅
The download daythe file’s write time🟡 it is the day this copy was written
The package’s org and identnowhere on disk❌
The package, from the install’s download/assets/<org>_<ident>/thumb.<crc>.png foldersa folder per package whose thumbnail was fetched🟡 only some packages, and the underscore may be inside the org or the ident, so each split is only a candidate
config/menu.jsonthe last map picked❌ one map

So the package is found by asking. SboxPackageLookup calls the s&box package API (https://public.facepunch.com/sbox/, the base address of Sandbox.Services.ServiceApi in the open-source engine): GET package/get/2/<org>.<ident> returns the title, the one-line summary and Version.Meta.PrimaryAsset (maps/construct_25.vmap), and GET package/find/2?q=type:map <name> searches. A candidate package is the package of a pack only when its primary asset is that pack’s map, so a search hit that merely sounds right is never taken. Candidates are the thumbnail folders first, then the search hits whose ident is the map’s name, the start of it, or begins with it, at most eight per pack. A pack no package is found for is listed as package unplaced and imported under the game’s map id.

The API is the importer’s only network call and only enrichment. Each call has a four-second timeout, the first failure ends all further calls in that run, and a package’s answer is kept for 30 days (served stale when offline) and a pack’s failure to be placed for a day, in the workspace’s import cache (sbox-package-*, sbox-pack-*). Offline, a pack is named by its map’s name and nothing blocks the import; an offline miss is not remembered, so the next online run places it. --no-cache leaves the cache out.

Revisions. --revision <crc or date> imports an older one: a CRC prefix of four or more digits, or yyyy-MM-dd; a prefix that fits several, or a day that does, is refused with the candidates listed. Without the flag the newest is imported and a yellow note lists the others one to a line, each with the flag that picks it. The pack of the revision imported is the map’s own layer; the other revisions of the same pack are not mounted behind it.

The pallet. com.facepunch.sbox.<org>.<ident> (ImportOrigins.PackagePalletId), such as com.facepunch.sbox.facepunch.construct, named for the package rather than the file inside it. A pack whose package is not known keeps com.facepunch.sbox.maps.<name>. The manifest carries metadata.source = package, metadata.package = facepunch.construct and metadata.packageRevision = the CRC imported (ImporterVersions.PackageKey, PackageRevisionKey); a newer cached revision than that CRC is an update.

Measured on the Construct package (construct_25, 6,580 placements): with only the game’s core folder behind it, 31 models and 574 of 587 materials were missing; with the cache mounted none are, and all 592 materials resolve. They use the s&box shaders in the shader table.

Measured on CS2, the materials the world of each addon map names, and how many resolve without and with its addon pack:

MapMaterialsWithout the addon packWith it
cs_shelter339239339
de_boulder365186365
de_debris229169229
de_eldorado189151189
de_fachwerk300196300
de_poseidon191158191

So a map’s reference to a base material or model resolves into pak01_dir.vpk.

vtex_c files decode to 8-bit RGBA and are written as PNG through Halcyon’s PngWriter. An HDR texture (BC6H) decodes to linear float RGB and is written as a Radiance .hdr. Any mip level can be asked for; the importer reads mip 0 by default, and an 8192 square lightmap is far smaller one mip down.

Stored formatUsed forState
DXT1 (BC1), DXT5 (BC3)Color, normals, directional lightmaps✅
BC7Color, baked shadow masks✅
BC6HIrradiance, cubemaps, probe atlases✅ (float, Radiance .hdr)
ATI1N (BC4), ATI2N (BC5)Ambient occlusion, normals, shadow masks✅
RGBA8888, BGRA8888Small and effect textures✅
R16F, RGBA16161616F and the other float formatsLUTs, paintkits✅ (float)
JPEG, PNG and WebP wrapped formatsPanorama UI images🟡 whatever the library decodes

Material textures stay at their stored size unless the workspace sets an import texture cap (import.maxTextureSize, with per-game overrides keyed by the game’s platform ID, such as com.facepunch.sbox; stock maps, Workshop items and s&box packages all take the importing game’s). A texture over the cap is scaled down before the importer repacks it, so the colors, normal maps (renormalized), masks, heights, and the metallic-roughness and emission textures built from them come out capped together. A high dynamic range texture, the lightmap pages, the sky images, the cubemaps and the probe atlases keep their size. The report and the console summary say the cap and how many textures it scaled down.

vmdl_c files decode to positions, normals, the first UV set, the lightmap’s coordinates (TEXCOORD3, on a draw call with baked lighting), the material’s second set (TEXCOORD1), and indices, one primitive per draw call with its material name. Both mesh layouts read: the embedded MVTX/MIDX/MDAT blocks (meshopt compressed) of CS2 and Deadlock, and the older vmesh_c of s&box. A texture set stored as three or four floats (R32G32B32_FLOAT, which Hammer meshes in Workshop maps such as liminal write) keeps its first two. Only the highest level of detail is read. Models write through Content’s GlbWriter; a primitive carries its material’s name, which the material import resolves.

What the import carries today (waves 1 and 2: geometry, collision, spawns and the map):

Source 2BecomesState
world.vwrld_c and its world nodesThe node list the map is built from✅
Static scene objectsTheir model’s triangles, moved to world space✅
Aggregate scene objects (baked, merged props)The draw call each aggregate mesh picks, with its fragment transform when it has one✅
Lower levels of detail of an aggregateLeft out🟡
Clutter scene objects (instanced foliage)Counted in the report❌ planned
Everything above, mergedOne mesh per piece with one primitive per slot, split by spatial cell into meshes/<map>/<cell>.glb✅
A placement’s tint (an aggregate mesh’s m_vTintColor in sRGB bytes, a static object’s in floats when its alpha is not 0)COLOR_0 on the placement’s vertices; a tint-masked material gets a slot of its own per tint, <material>@tint<rrggbb>, mapped to a tinted variant (see Tints)✅
A draw call’s COLOR stream, on a material with F_PAINT_VERTEX_COLORSCOLOR_0, taken from sRGB to linear and multiplied by the placement’s tint✅
A draw call’s blend paint (TEXCOORD4, four bytes), on a layered materialCOLOR_1, whose red paints the second layer on (see Layered materials)✅
Overlays (csgo_static_overlay, F_OVERLAY, F_DEPTHBIAS)depthBias of the object’s m_nOverlayRenderOrder plus one step; an order above 0 is a slot of its own, <material>@order<n>, mapped to a variant materials/<path>.order-<n>.dh-mat✅
Surfaces of an effect shader (csgo_effects)Left out, and listed under hiddenMaterials in the report❌
The second UV set, where a material reads its detail or tint mask from itUV set 2 (Source2Surfaces.SecondaryUvSet) of that material’s primitives✅
The mapmaps/<map>.dh-map whose world instance names the meshes/<map> folder, plus pallet.dh (declaring the game pallet), the import report and a gray materials/untextured.dh-mat for any slot whose material could not be imported; dh build compiles it✅
vents_c entity lumpsEvery entity counted per class in the report, with its connections read🟡
Doors (func_door, func_door_rotating, prop_door_rotating), func_movelinear, func_rotatingObjects carrying a dh.mover, each carrying its model as a piece of its own (meshes/<map>/logic_<class>_<index>.glb); see Logic🟡
func_button, triggers, teleports, logic_auto, logic_relay, logic_timer, and the connections between themDH’s logic objects and action lists, by the Source importer’s own rules; see Logic🟡
Player spawnsObjects carrying a dh.spawnPoint, named by team: info_player_terrorist and info_player_counterterrorist (CS2), info_team_spawn with its teamnumber (Deadlock), info_player_start (s&box), info_deathmatch_spawn✅
Camerasspawn1 to spawn4 at spawns, the first the map’s thumbnail with autoCapture on (no pack carries a preview image)✅
Convex collision shapes (world_physics)hull colliders; shapes that stop nothing a player is (grenade and NPC clips, line-of-sight blockers, sky) and shapes that exclude player are left out✅
Spheres and capsulesA hull over the swept shape (26 points a sphere)🟡
Triangle-mesh collision shapesCell-split GLB pieces under an invisible collisionProxy slot, which the map’s mesh collision cooks✅
Drawn geometryCollider off per piece (a path-keyed part override), so only the physics file is solid✅
Materials (vmat_c) and their texturesdh.material files and repacked PNGs in the game pallet (or the map pallet for map-pack content); see Materials✅ (layered ones 🟡)
The 2D sky (env_sky, a sky.vfx material holding one cube texture)An equirectangular textures/skies/<sky>.hdr set as the map’s lighting.skyImage, with the material’s exposure bias, the entity’s tint and its brightness folded in; see The sky✅
The 3D skybox (skybox_reference, the sky map it names)The sky map’s world meshed as backdrop_<cell>.glb pieces beside the world’s and declared as the map’s backdrop, with the sky_camera’s scale and the fog; see The 3D skybox✅
The sun and the sky ambient (light_environment)The map’s sunDirection, sunColor and sunIntensity, and its hemisphere ambient; see Lighting✅
The tonemap curve (post_processing_volume’s .vpost)A straight curve: render.tonemap none at its exposure; a filmic one: render.tonemap hable with its coefficients, the 2.8 folded in✅
The irradiance lightmap, the world’s and the 3D skybox’sAn imported lightmap beside the map, one chart per atlas, read at up to 4096 texels✅
directional_irradiance, the SH2 setsLeft out: the atlas carries no direction❌
direct_light_shadows, direct_light_indices, direct_light_strengthsLeft out: the sun stays live and casts the engine’s own shadows🟡
Light probe volumes (env_combined_light_probe_volume, env_light_probe_volume)One light probe group✅
Cubemaps (env_combined_light_probe_volume, env_cubemap_box, env_cubemap)Box reflection probes and their cook✅
Static lights (directlight 1: light_barn, light_rect, light_omni2, light_spot, light_omni)Nothing more: their light is in the lightmap and the probes✅
Stationary and dynamic lights (directlight 3 and 2, or a bakedshadowindex)Counted in the report❌

The frame. Source 2 shares Source’s axes and units (inches, Z up, +X forward, +Y left), so the import uses the conversion the Source importer uses, ValveWorldSpace in Content: a point is (-y, z, -x) at 0.0254 meters an inch. Source 2 winds triangles counterclockwise, as DH does, so unlike Source they are not turned over; a mirroring transform is the one thing that turns one. On de_ancient, 99.95 percent of triangles wind against the face normal the file stores.

The report. Beside the GLB the importer writes maps/<map>.import-report.json: the node, object and aggregate counts, the vertex and triangle counts, the entity classes, the spawns, every file written and what was left out. The console prints a Timings: line after it: the total and each step slowest first (findMap, mount, findWorld, layout, geometry, entities, logicModels, collision, sky, skybox, lighting, materials, write, logic, lightmap, probes, reflections, map). The timings are printed and left out of the file, and every file is written only when its content differs from what is on disk, so importing the same map again leaves the pallet as it is. The map draws every piece in its geometry folder, so a piece an earlier import wrote and this one did not (a split that changed with an importer version) is deleted. A texture derived from other textures (a metallic-roughness pack, a tinted color, an emission) is named textures/<first texture>.<hash>.<kind>.png by a hash of its inputs and recipe, so materials that make the same texture share one file, and an import deletes a derived texture no material of the pallet names any more.

From the installed games on the machine this was written on (CS2 patch 1.41.8.8):

MapStatic objectsAggregate meshesModelsTrianglesVerticesMaterialsSpawns
de_ancient (CS2)994,716 (of 273 aggregates; 17 lower-LOD left out)3689,188,7307,571,61819212 T, 12 CT
de_dust2 (CS2)1383,472 (of 379)5174,545,8334,393,12727615 T, 15 CT
dl_midtown (Deadlock)84325,451 (of 911; 71 lower-LOD left out)1,35128,286,69234,660,20158789 info_team_spawn
dev/preview_flat (s&box)2021,8501,73631
construct_25 (s&box, from the cache)6,57706,21711,573,44012,451,10559229 info_player_start

A Deadlock map is large: dl_midtown is 1.4 GB of geometry in one world node, and de_ancient has one node too, so the split is by spatial cell within the world, not by node. Each placement goes to the horizontal cell (X and Z in DH’s frame) its bounds center falls in; a cell that would write more than the piece limit is halved in both directions, again and again, until it fits (down to 5 m). Pieces are named for their cell (x0zn1, x0zn1-03 once halved), so the same map always imports to the same files, and the map’s world instance names the folder (a folder of pieces). The splitter is GeometryCells in Content, so the Source importer can use it too.

Both numbers are importer options with measured defaults (Source2MapImportOptions.CellSize, MaxPieceBytes): 128 m cells and 32 MiB pieces. Measured on de_ancient, a 64 m or 100 m cell gave 25 pieces and a 128 m cell with a 32 MiB limit gave 34, the largest 29 MB against 43 MB at the 48 MiB limit; dl_midtown gave 134 pieces at 128 m / 32 MiB and 282 at 64 m / 16 MiB. A placement’s cost counts the vertex colors its surfaces may carry (Source2WorldPlan.ItemsFor): de_ancient, whose tinted and layered surfaces nearly all carry a set, now splits into 49 pieces of at most 24.7 MB.

A piece that would hold no triangle is never written or named: a cell whose placements draw only left-out materials (de_ancient’s sun disc glow, the one placement of the 3D skybox cell backdrop_x1z1) would otherwise be an empty GLB, which the build refuses as an empty glTF array. Source2WorldPiece.IsEmpty is the test, for the world and the backdrop alike.

DH has no concave collider and no collider on a geometry piece, so the physics file maps like this: each convex shape and each sphere or capsule that stops players is a hull shape of one dh.collider, owned by a plain object named collision that stands at the origin, so its points are in world meters (ImportedMapParts.CollisionObject), capped at 8,000 largest-first (Source2MapImportDefaults.MaxHulls); the triangle meshes are written as collision_<cell>.glb pieces drawn by the collisionProxy material (fully transparent) and cooked by the map’s default "mesh" collision. DH cooks collision from all the geometry, so each drawn piece carries a path-keyed part override whose dh.collider lists no shapes (ImportedMapParts.ColliderOverride).

MapPiecesLargest pieceHull collidersCollision meshSpawns
de_ancient4924.7 MB171 of 295 shapes (124 are not for players)6 meshes, 690,624 triangles in 4 pieces12 T, 12 CT
dl_midtown13431.5 MB8,000 of 33,797 (25,797 over the limit)8 meshes, 2,392,646 triangles in 53 pieces89 info_team_spawn (44 per team, one neutral); info_koth_spawn_location and trooper spawns are not player spawns
dev/preview_flat20.04 MB01 mesh, 436 triangles1 info_player_start

de_ancient’s geometry and collision import in about five seconds (the materials add the minutes below) and dh build compiles it in about eight (cooking 39 nodes); dl_midtown imports in about eighteen.

Source 2 kept Source’s entity I/O: the same classes, nearly the same keyvalues, and connections of target, input, parameter, delay, timesToFire, stored as typed records in the lump rather than as text. So the import runs the Source importer’s own logic pass, ValveLogic in Content, which maps the classes onto DH’s logic components and the connections onto action lists. Each class’s rules, spawnflags and dropped outputs are the ones the Source page lists; this section says only what is Source 2’s own.

Each importer hands the pass its entities as records (class, keyvalues, connections, origin and angles: a BSP’s BspEntity, a lump’s Source2Entity) and the geometry each entity stands for. Source’s is a brush model drawn by a piece of the world model; Source 2 has no brushes, so a door’s, button’s or trigger’s solid is the compiled model its model key names.

Source 2BecomesCS2Deadlocks&box
A door’s or button’s model (maps/<map>/entities/*.vmdl, or a prop’s models/*.vmdl)Its bounds (from its physics shapes, else its mesh, scaled by scales) are the solid the pass measures travel, boxes and hinges by; its physics hulls, in DH meters, are the piece’s colliders✅❔ no doors❔ no doors
A model that draws anythingA piece of its own, logic_<class>_<index>.glb, in the map’s geometry folder, filed under its mover as an override with parent, static: false and a dh.collider of its hulls (an empty one when it has none, so the mesh collision never cooks a door shut)✅❔❔
A model that draws nothing (CS2’s func_doors and func_buttons are mostly invisible, a prop_dynamic parented to them drawing the door)No piece: the mover’s own box body, as for a Source door with rendermode 10✅❔❔
A model parented to a door or a button (its leaf, its handle)A piece the mover carries, with its hulls unless its solid is 0✅❔❔
prop_door_rotatingSource’s prop door, its model a piece rather than an instance✅❔❔
movedirIn the entity’s own frame, as its model is (a door turned 45 degrees with movedir 0 0 0 slides along its own X), turned into the world’s for the dh.mover✅❔❔
[PR#] names (a prefab’s)The world lump’s names drop the placeholder; a child lump, a prefab instance of its own, writes pr<n>_ in its place, so its connections reach only its own entities✅❔❔
Typed keyvaluesRead as Source’s text: true and false are 1 and 0, keys match case-insensitively✅✅✅
Triggers, teleports, relays, timers, logic_autoSource’s rules, unchanged: they fell out of the shared pass, the triggers measured by their model’s physics bounds🟡 untested in play❔❔
point_script, cs_script, vscriptsNot read: the scripts cannot be converted faithfully❌❌❌
s&box’s ent_door and its C# componentsNot read❌—❌
A door that spawns openWritten as Source’s is: the mover stands closed where the map built it, with startOpen✅❔❔

The step that reads the models is logicModels, and the pass is logic. The report’s logic block is the Source importer’s (movers, prop doors, buttons, notMapped, connections by input and by reason, dropped naming each), and logicPieces lists the pieces written.

Every installed map was surveyed on the machine this was written on (72 CS2 maps, the stock ones and the Workshop’s, 9 Deadlock maps, 20 s&box maps with a world). Across CS2’s: 937 func_buttons on 24 maps, 992 trigger_multiples on 32, 478 logic_relays on 34, 101 logic_autos on 41, 55 prop_door_rotatings on 17, 43 func_movelinears on 5, 31 func_doors on 2 and 29 func_door_rotatings on 5, and 15,119 connections in all; scripts are rare beside them (14 point_scripts on 8 maps, 15 vscripts keys, 12 cs_script keys). Deadlock’s 1,078 connections are its own classes and it has no doors; s&box keeps gameplay in C#, with 6 ent_doors and 2 connections.

MapMoversProp doorsButtonsPiecesConnections mapped of total
site48 (CS2 Workshop item 3255945975)3027596854 of 1,048

Most of site48’s connections drive what DH has no logic for: skins, lights, sounds and trigger_hurt (438 are a damage source), so 53 of the 54 mapped are a button’s Toggle of its door.

A map’s 2D sky is its last env_sky that is not disabled (startdisabled, enabled): de_ancient’s is a disabled, lighting-only one, so it imports no sky image. The entity names a sky.vfx material (materials/skybox/sky_de_nuke.vmat) whose g_tSkyTexture is one cube texture, usually BC6H at 256 to 2048 pixels a face. The import decodes all six faces at the finest mip no wider than 512 pixels (the widest image it writes is 2048 across), multiplies them by

2^(g_flBrightnessExposureBias + g_flRenderOnlyExposureBias) x tint_color / 255 x brightnessscale

and resamples them to the lat-long textures/skies/<material>.hdr through the shared cube (ValveCube in Content, the same code Source’s sky and cubemap imports run). An 8-bit cube (F_TEXTURE_FORMAT2 1) is taken from sRGB to linear first; a YCoCg or RGBM one is reported and left out. The map’s lighting.skyImage points at the image. dust2’s exposure bias is 0.765 stops, cs_construct’s is 1 (its image is twice as bright as its cube); both tints are white.

The faces. The six faces (+X, -X, +Y, -Y, +Z, -Z) sit on the cube under the ordinary cube lookup of the world direction in Source 2’s Z-up axes, the table Source’s cubemaps use (ValveCube.CubemapFrames) and the lookup the library’s own sky shader makes:

FaceLooks alongImage rightImage up
+X+X-Z+Y
-X-X+Z+Y
+Y+Y+X-Z
-Y-Y+X+Z
+Z+Z+X+Y
-Z-Z-X+Y

It is measured too, on de_nuke, de_dust2 and cs_construct: every other placement of a face (its seven turns and mirrors) continues worse across the seams, a cube turned or mirrored as a whole continues exactly as well, so the turn is fixed by the brightest feature above the horizon lying under the map’s light_environment in azimuth (de_nuke 6 degrees off, de_dust2 16), and the zenith is the blue and the nadir the ground. The env_sky’s own angles turn the cube in the engine and the import turns it the same way before resampling (none of the CS2 maps states any; s&box’s Construct turns its HDRI 279.7 degrees), and the report names them under sky.angles.

A skybox_reference in the map names its 3D skybox, a separate map pack: a game ships it as a loose pack under maps (maps/prefabs/de_nuke/de_nuke_skybox02.vpk), a Workshop item inside the item’s own pack (maps/cs_construct_skybox2.vpk), s&box’s gamecache as a package named for the hash of that same path (Construct’s construct_25_sky). The pack is found through whichever layer of the mount stack holds maps/<target>.vpk (a game folder, an item pack, the content cache, with a pack that is already a layer of its own used as it stands), never through a path the importer knows. The import mounts it as part of the map’s own content, runs the world mesher on the sky map’s world (its static and aggregate placements, with its materials and textures written to the map’s pallet) and writes the pieces as meshes/<map>/backdrop_<cell>.glb beside the world’s. The map’s backdrop then names every one of them as a part, with a backdrop folder for its records; the cells are as wide as the world’s once the sky is drawn at its scale, and the sky pieces take no collider override because a backdrop has no collision.

The sky map’s sky_camera and the reference place it. A point p of the sky map appears at scale * (p - origin) + reference in the map (angles are ignored, as in Source), which is DH’s anchor + eye / scale with

anchor = sky_camera.origin - skybox_reference.origin / scale

in Source 2’s inches, then converted to DH meters. scale is the camera’s (16 on de_nuke, de_dust2, de_mirage and cs_construct, 1 on de_ancient; Valve’s 16 when it states none). The backdrop’s fog is the camera’s own Source 1 keys when it states them (fogenable, fogstart, fogend, fogcolor, fogmaxdensity: cs_construct), otherwise the sky map’s enabled env_gradient_fog (de_nuke; startdisabled and s&box’s fogenabled read, its density the fogmaxopacity times the fogstrength). Both are authored at full size, which is how the backdrop measures fog; only the linear ramp is read, not the falloff exponents or height fog.

The sky map’s own lightmap lights its pieces: its atlas is a second chart of the map’s lightmap, beside the world’s. Its probes and cubemaps are not imported (they light it where it is built, small, and a backdrop object samples the map’s group where it appears), and its light_environment repeats the map’s. The report’s sky and backdrop sections give the image, its gain, the pack, the anchor, the scale, the fog and where it came from, the pieces and their triangle counts.

Source 2 lights a map with a live sun, an irradiance lightmap, light probe volumes and cubemaps, all baked by Hammer except the sun. The import carries each into its DH counterpart. What every map in the survey holds:

MapLightmapIrradianceDirectionShadow dataProbesCubemapsLights
CS2 de_nuke8.28192, BC6Hdirectional_irradiance 4096, DXT1direct_light_shadows 8192, BC7atlas 200x188x648, BC6H, 94 volumesarray of 93, 256, BC6H128 static and 14 stationary light_barn, 1 dynamic; 25 static light_rect
its 3D skybox8.2102410241024, BC4atlas, 1 volume1sun only
CS2 de_dust28.2819240968192, BC7atlas 192x176x720, 43 volumes4324 static light_rect, 2 light_omni2, 2 static and 2 stationary light_barn
CS2 de_ancient8.2819240968192, BC7atlas 160x176x888, 82 volumes8235 static light_rect, 22 light_omni2, 6 stationary light_barn
cs_construct (3085087704)8.2204820482048, BC4atlas 72x96x288, 2 volumes236 static
s&box Construct8.181928192, BC7direct_light_indices 2048, direct_light_strengths 8192a texture per volume, 141 volumesarray50 light_spot, 64 light_omni, all static

The lightmap version (m_nLightmapVersionNumber) is 8 on every map; the minor version (m_nLightmapGameVersionNumber) says which shadow data sits beside the irradiance. A world lists its lightmaps by name in m_lightMaps, and the importer finds the irradiance one there. Every lightmapped draw call (m_bHasBakedLightingFromLightMap) keeps its coordinates in TEXCOORD3, on every map in the table, and no other draw call carries that set; TEXCOORD1, which a fifth of them carry beside it, is the material’s second set (Source2TextureSets). On de_nuke 3,239 of 4,318 draw calls are lightmapped. The rest are lit by the probes, which is why the probes matter as much as the lightmap.

The map’s first enabled light_environment is its sun:

Source 2DHRule
angles (pitch yaw roll)lighting.sunDirectionThe entity’s forward, (cos yaw cos pitch, sin yaw cos pitch, -sin pitch), taken to DH’s axes: the direction the light travels
color (sRGB bytes, an alpha byte dropped)lighting.sunColorOver 255, as sRGB
brightness times brightnessscalelighting.sunIntensityTimes 0.98
skycolor, skyintensity, skyambientbouncelighting.ambientSky, ambientGround, ambientIntensity, ambientMode: "hemisphere"Source 2’s own sky ambient, below
plain Lambert (N.L)lighting.halfLambert: 0Source 2 does not wrap its diffuse

The units. Source 2 adds its sun, srgbToLinear(color) * brightness * N.L, to the irradiance texel in one linear unit, the way Source adds its worldlights to its luxels. So the sun and the lightmap take one factor, the one the Source importer measured for DH’s atlas against its realtime lights: 0.98 (ImportedLightmapBuilder.AtlasUnitsPerLightUnit). The irradiance holds none of the sun, which Source 2 adds per pixel under its baked shadow mask. Measured on de_nuke, from 21,707 texels sampled at triangle centers: texels facing the sun read a median irradiance of 0.58 where the mask says lit and 0.19 where it says shadowed. That gap is under a quarter of the 1.7 or more that a 2.4 sun at a cosine of 0.7 would add. So the lightmap is written with bakeSun: false, and DH’s sun is live everywhere and casts its own shadows.

The direction. It was measured against the baked shadow mask (direct_light_shadows, the sun’s channel from its bakedshadowindex). A ray toward the sun from each sampled texel, against the whole world, is blocked exactly where the mask says shadowed on 98.8 percent of 9,271 texels. With the yaw ten degrees off the figure is 97.6, with the pitch ten degrees off it is 96.4, and with the direction flipped or turned a quarter it is 85 to 89.

The ambient. Where no probe volume covers a surface, Source 2 lights it with a sky term. That term is linear in the normal’s height: up = sky and down = 2 skyBounce + sunBounce (|down| - down), where sky is the sky color in linear light times its intensity, skyBounce half of it times the bounce color, and sunBounce half the sun’s light times the bounce color. DH’s hemisphere blends from its ground color to its sky color by the normal’s height above the horizon and holds the ground color below it. So the import writes the sky as Source 2’s up value and the ground as its horizon value, which matches every normal at or above the horizon and holds the horizon’s value below it. de_nuke’s comes to a sky-blue up of (0.12, 0.43, 1.03) and nothing from below. The report gives all three as lighting.sun.ambientUp, ambientSide and ambientDown.

The tonemap. The master post_processing_volume names a .vpost whose tonemap is Hable’s (Uncharted 2) curve, applied after a fixed 2.8 scale. de_nuke’s and de_dust2’s curves are straight lines: no shoulder, white at 1.5 and 1.65. A straight curve maps radiance to 2^bias * x / white up to white, so the map gets render.tonemap: "none" and that exposure, 0.667 on de_nuke. de_ancient’s curve is Hable’s filmic one, which the map gets as the hable tonemap: render.hable states the curve’s A to F as the .vpost does, and because Source 2 resolves curve(min(x * 2.8, white * 2.8)) / curve(white * 2.8), the fixed 2.8 (the engine’s own pre-scale, not a value in the .vpost) is folded in: render.exposure is 2^bias * 2.8 and render.hable.whitePoint is the white point times 2.8, so DH’s resolve (clamp at the white point, curve, divide by the curve there) is Source 2’s. The curve math is Core’s HableCurve. A curve that falls below zero (de_boulder’s: a faint shoulder over a toe that cancels, so the curve and its white are both negative and Source 2’s division makes the picture positive) is written as its exact mirror, toeNumerator as 2F - E and linearAngle as 2 - C. Every value is clamped into the range the compiler accepts, and a curve that never rises above black by its white point, which no installed map states, keeps the engine’s curve with a warning. cs_construct states no curve. CS2’s auto exposure range (minexposure, maxexposure) is not read.

Each world’s irradiance (BC6H, linear) is read at its finest mip no wider than 4096 texels (Source2LightmapDefaults.MaxAtlasSize). That is the directional map’s own resolution, so on CS2’s 8192 atlases, which carry a second mip, it keeps a quarter of the texels. s&box’s Construct stores its 8192 irradiance with no smaller mip, so it is read whole and its chart is shrunk to 4092, half its width, by a bilinear filter that averages each two by two block. A vertex addresses the atlas at TEXCOORD3 * m_vLightmapUvScale, with no per-object scale or bias.

An atlas becomes one chart: the rectangle of texels its lightmapped vertices read, bilinear neighbors included. It is cut as is, or shrunk by the least factor that fits it, with its 2 texel gutter, into its power of two and a 4096 page (Source2LightmapRect.Fit). A 4096 atlas becomes 4092 and a 2048 one 2044, a shrink of a tenth of a percent, so cs_construct’s two 2044 charts share a page. The 3D skybox’s atlas is a second chart. de_nuke comes to two 4096 pages, its world on one and its 1020 sky on the other. Each texel is the irradiance times 0.98, and the chart carries no direction.

The geometry is written in pieces, so the lightmap and the other bakes beside the map are fingerprinted against the pieces joined in path order, the model the build and the runtime join. The vertices are numbered in that order too. A slot whose placements mix lightmapped and unlit draw calls keeps each vertex’s own coordinates, so only the unlit ones fall back to the probes.

What was lost:

  • The direction. directional_irradiance stores, per texel, a dominant direction in the surface’s tangent frame (red and green), how much of the light is not directional (blue) and a specular occlusion (alpha). The engine bends the irradiance toward the normal map with it. DH’s MonoSH direction is a world-space vector per texel, so carrying it needs the tangent frame at every texel: rasterizing each lightmapped triangle into the atlas with its interpolated normal and tangent, which the mesher does not read yet. Without it the atlas is flat, and normal maps catch none of the baked light. The specular occlusion is dropped with it.
  • Resolution. On an 8192 atlas, three quarters of the irradiance’s texels.
  • The baked shadow data. direct_light_shadows (8.2) and direct_light_indices/direct_light_strengths (8.1) shadow the live sun and the stationary lights. The sun casts DH’s own shadows instead, and the stationary lights are not imported.

A probe volume is a box on a grid, with one probe at the center of each cell. Each probe is an ambient cube: six colors stacked as six runs of slices along a volume texture’s depth, in the order +X, +Y, +Z, -X, -Y, -Z of the world. Source 2 weights them by the normal’s squares, as Source weights its leaf ambient cubes. They are BC6H and read as is, since only SteamVR Home’s encode RGBM. CS2 keeps every volume of a map in one atlas texture, each volume a box of it at light_probe_atlas_x/y/z of size light_probe_size_x/y/z. s&box keeps a texture per volume.

The probes become one light probe group, maps/<map>.dh-probegroup, contents: "indirect": they hold the sky’s fill, the bounce and the static lights, and none of the sun or the stationary lights. Each probe is placed at its cell center, turned with its volume, and its cube is projected to order-2 harmonics by AmbientCube.ToShL2 after its faces are taken to DH’s axes. The partition is axis-aligned planes that split the probes at the median of their widest spread until a cell holds 8. Each cell then lists the 24 probes nearest its middle, taken from the volumes that cover that middle with the highest indoor_outdoor_level, or from every volume when none covers it. A group holds at most 262,144 probes. When a map’s volumes hold more, the largest ones are thinned first, each read at the least step that brings it under one cap, the highest cap the budget allows, so a small indoor volume keeps every probe (Source2ProbeImporter.Strides). The report gives the thinned volumes and the largest step, and a warning says so.

What was lost: Source 2 blends up to four volumes by their edge_fade_dists; the group blends the listed probes by inverse squared distance, and its cells do not end at walls. The volumes’ shadow data (_dlshd, _dli, _dls) goes with the stationary lights. The 3D skybox’s volumes are left out.

CS2 keeps a map’s cubemaps in one cube array (cubemaptexture), one layer each at array_index, in BC6H read as is. An env_combined_light_probe_volume or an env_cubemap_box becomes a reflection probe on its box (its corners turned and enclosed), box-projected as Source 2 projects it. An env_cubemap becomes one on the box around its influenceradius, with no projection. Each captures at its entity’s origin. Its importance is its indoor_outdoor_level, and it eases in over its largest edge_fade_dists, at least 5 cm. The faces are read at 128 (ImportedReflections.MaxProbeResolution, Source’s own cap) and cooked through Content’s ImportedReflections, the same code the Source importer cooks through. de_nuke’s 93 cubemaps come to 93 MB of cook. s&box’s Construct names 141 cubemaps in an array that no layer of the mount stack holds (the package does not ship it), so it gets none, and a warning says so.

Source 2 lights a static light (directlight 1) entirely into the lightmap and the probes, so it needs nothing more. A stationary one (directlight 3, or any light with a bakedshadowindex of 0 to 3) and a dynamic one (2) are lit per pixel, the stationary ones under their channel of the baked shadow mask. Those are not imported yet: their bounce is in the lightmap and their direct light is missing (de_nuke’s 14 stationary and 1 dynamic light_barn, a rectangular light with barn doors that DH has no shape for). The report counts every light under lighting.lights by class and mode.

lighting in the import report gives the sun as read and as written (sun), the lights by class and mode, the post-processing resource and what became of its tonemap, and each atlas under lightmap and backdropLightmap: its version, its scale, the size it was read at, the texels cut out, the chart’s size, and the vertices lightmapped and left to the probes. It also gives the pages, probes (volumes, layout, probes, stride, cells, depth) and reflections (cubemaps, probes, box-projected, resolution).

Each slot a piece names (the vmat path without its extension) is read through the mount stack, mapped to a dh.material (Source2MaterialMapper) and written, with the textures it needs, into the pallet that owns the layer that served it: the game’s (com.valvesoftware.cs2, folder com.valvesoftware/cs2) for the game’s own files, the map’s for what its pack carries. A material’s textures are looked up from its own layer downward, so a pallet never depends on one above it; the map pallet’s manifest declares the game pallet. The pallet writer is Core’s GeneratedPalletSet, shared with the Source importer: files are written only when they change, and the import.dh-import index records what each texture was made from, so a second import skips them. A texture’s hash covers its source bytes, its recipe and the textures version of Core’s ImporterStageVersions.Source2Map, which is bumped when a converted texture changes; its materials version is the index’s own and settles every texture again. The same hash keys the workspace’s import cache, so a texture another map or an earlier import decoded is written from there.

The materials are converted on every core: every material the pieces name, then every tinted or ordered variant of one, is mapped and its textures decoded and written several at a time, and the report then takes each slot in placement order, so the files and the report are what a one-at-a-time import gives. de_nuke’s 265 materials and 939 textures took 68 s one at a time and take 19 s on 16 cores.

Source 2DHState
g_tColor (and layer 1’s g_tColor1)baseColor, PNG at the largest mip (DH makes the mips)✅
Normal mapnormal; Source 2 normals are +Y up like DH’s (measured: the normal’s gradient and the height map’s agree), so nothing is flipped✅
RoughnessThe metallicRoughness texture’s green, from the normal’s alpha (or g_tRoughness), with the material’s brightness and contrast✅
MetalnessThe same texture’s blue: g_tMetalness, or the color alpha when F_METALNESS_TEXTURE; g_flMetalness as a scalar✅
g_tAmbientOcclusionocclusion✅
Self-illuminationemissive mask (mask times albedo by g_flSelfIllumAlbedoFactor), emissiveColor from the tint, emissiveIntensity as g_flSelfIllumScale times exp2(g_flSelfIllumBrightness)✅
g_vColorTint, g_flModelTintAmount, F_NOTINT, tint masktint, with the placement’s share on the vertices; with F_TINT_MASK, a multiplying decal of the whole tint masked by the tint mask’s red; see Tints✅
Color brightness, contrast, saturationBaked into a per-material color texture🟡 formula assumed
F_ALPHA_TEST, F_TRANSLUCENT, F_ADDITIVE_BLENDalphaMode mask (with g_flAlphaTestReference) or blend; see Blends. A material with none of these is opaque, written explicitly; its color texture’s alpha is never read as opacity🟡
F_BLEND_MODE (csgo_static_overlay, csgo_unlitgeneric)0 opaque, 1 translucent, 2 alpha test, 3 blendMode: mod2x, 4 additive, 5 multiply, 6 mod-then-add as multiply; see Blends🟡 mod-then-add approximated
F_TEXTURE_ANIMATIONflipbook: g_vAnimationGrid columns and rows, g_nNumAnimationCells frames, 1 / g_flAnimationTimePerFrame fps (0 holds the first cell); a start time or fixed frame (g_flAnimationTimeOffset, g_flAnimationFrame) is dropped with a warning✅
g_vTexCoordScrollSpeeduvScroll✅
F_RENDER_BACKFACESrenderFace both✅
g_tHeight, g_tHeight1height with heightScale Source2MaterialImportDefaults.HeightScale🟡 depth not read from the material
g_tDetaildetail block with g_vDetailTexCoordScale; F_DETAIL_TEXTURE 1 blends mulx2, 2 and 4 overlay, 3 (a detail normal alone) has no color detail; UV set 2 when F_SECONDARY_UV and g_bUseSecondaryUvForDetailTexture say so🟡 mask and detail normal dropped
g_vTexCoordScaleuvScale; rotation and offset dropped with a warning🟡
F_LAYERS, csgo_environment_blend, 2-way blendsThe first layer as the base, the second as the detail block painted on by COLOR_1’s red; a third layer is dropped, counted and listed in the report (see Layered materials)🟡 two layers
csgo_water, csgo_water_fancy, s&box water_simple and construct_waterA flat water block over the shader’s own fog, normal map and amounts; see Water🟡
Transmissive foliage, decal textures, anisotropyDropped or the untextured material, with a warning❌
Physics surface propertiesNot mapped❌
ShaderSeen onState
csgo_environment, csgo_environment_blendde_ancient✅ / first two layers
csgo_simple_2way_blendcs_construct (Workshop), 2 materials; none on de_dust2, de_ancient or de_nuke✅ both layers, its edge-breaking mask as the detail’s blendMask
csgo_vertexlitgeneric, csgo_lightmappedgenericde_dust2✅ / both layers when F_LAYERS
csgo_complex, csgo_simple, csgo_foliage, genericall three✅
csgo_static_overlayall three✅ depth bias, real blend modes
csgo_unlitgeneric, csgo_black_unlitboth✅ unlit
csgo_effectsall three❌ left out
vr_complex, vr_simple🟡 mapped by name, not seen on a map here
s&box complex, simple, genericconstruct_25: 384, 1 and 5 materials✅ color and normal; its packed g_tRma map is not read yet
s&box blendableconstruct_25, 102 materials🟡 the first layer, the second painted on by vertex color as the detail block; a third layer (g_tColorC) is dropped and counted
s&box static_overlayconstruct_25, 68 decals✅ depth bias and its F_BLEND_MODE combo, as csgo_static_overlay
s&box foliage, bark, tarp2construct_25, 7, 1 and 2 materials🟡 alpha test, both faces and the g_flTintColor tint; wind sway, parallax and the tarp’s scrolling detail are not carried
s&box glassconstruct_25, 20 panes✅ a glass pane tinted by its TextureColor constant
s&box water_simple, construct_waterconstruct_25, 1 construct_water (the pond)🟡 a flat water block; see Water
s&box simple_3layer_parallaxconstruct_25, 1 material❌ untextured: a layered blind
csgo_water, csgo_water_fancycs_construct (Workshop) dev_water2; de_nuke, de_ancient and de_inferno 1 each🟡 a flat water block; see Water
Any unknown shader❌ untextured

A texture or material the library cannot read (a block type newer than the pinned library) is a warning, never a crash: a failed base color makes that material the untextured one, any other failed texture is left off. The importer reads the largest mip only; Half-Life: Alyx bakes roughness into its mips, which is unhandled because Alyx is not installed here.

A layered material blends two or three surface layers by vertex paint. The paint is four bytes a vertex in the draw call’s fifth texture set (TEXCOORD4): red paints the second layer on, green the third. The import carries the four as COLOR_1 on the surfaces of a layered material, and maps the second layer as the material’s detail block, painted on by vertexWeight: "r": g_tColor2 or g_tLayer2Color blended lerp, its normal, its roughness and metalness packed the way the first layer’s are (written as a content-named <texture>.<hash>.mr.png, shared with every layer and material made from the same textures), its occlusion and height, g_vTexCoordScale2 as the block’s uvScale, and g_vColorTint2, g_vLayer2Tint or g_vTextureColorTint2 as its tint. The second layer takes the place of a g_tDetail, which is dropped with detailReplaced.

csgo_simple_2way_blend names its layers A and B (g_tColorA, g_tNormalA, g_tColorB, g_tNormalB, with roughness baked into each normal’s alpha and g_flMetalnessA a constant). Layer A is the base and layer B the detail block, painted on the same way. Its g_tMask, a noise texture Source 2 uses to break up the edge between the two layers, becomes the detail’s blendMask, a linear data texture read from its red channel, so the edge follows the noise instead of the mesh. The material has no scale or softness of its own for the mask, so it samples at the detail’s uvScale (g_vTexCoordScale2) and keeps the default blendSoftness. The mask is written only on a layer painted on by vertex weight, which every mapped second layer is. csgo_environment_blend has no mask texture; it blends by its two heights. g_tHeight1 is the base’s height and g_tHeight2 the detail’s, both linear data textures read from the red channel (the other channels of the CS2 height textures carry nothing the importer reads), and the detail gets heightBlend so moss fills the cracks before it climbs the stones. g_flBlendSoftness2 already runs 0 to 1 on the shipped materials (0.004 to 0.65 on de_ancient), so it becomes blendSoftness as it is, clamped to that range; a material that states none keeps the default. A second layer with no g_tHeight2 is painted by the vertex weight alone.

DH paints one layer on per material, so a third layer is dropped; the report counts it in approximatedLayers and names it in approximatedMaterials. Source 2 blends the layers by their height maps and a softness; DH lerps them by the paint alone, so a transition is a smooth fade where Source 2 draws stones poking through the moss. A two-texture material (F_TWOTEXTURE) multiplies its second texture in rather than painting it on, and is still drawn as its first.

Measured (CS2 patch 1.41.8.8):

MapLayered materialsSecond layer painted onThird layer dropped
de_ancient94 (77 two-layer, 17 three-layer, csgo_environment_blend)9317, and one two-texture csgo_unlitgeneric drawn as its first
de_dust260 (two-layer csgo_lightmappedgeneric with F_LAYERS)600
de_nuke660

A csgo_environment_blend carries more height parameters than DH’s heightBlend reads. The importer maps g_tHeight1, g_tHeight2 and g_flBlendSoftness2 and leaves the rest:

  • Distance widening. CS2 widens the softness by the height texture’s mip level (g_flBlendSoftnessDistanceModifierStrength) so a far edge does not alias; DH’s softness is the same at any distance. A material that sets the strength above 0 is counted in the report under blendSoftnessDistance. On de_ancient 37 of the 93 set it, all to 1.
  • Height scale and zero point. g_flHeightMapScale1 and 2 (and 3) and g_flHeightMapZeroPoint1 and 2 rescale each layer’s height before the comparison. DH compares the raw red channels, so a layer with a scale well above 1, below 0 or a zero point of 0.84 compares differently than in CS2. They are not mapped: the shader’s formula is not known here, and a negative scale inverts the layer’s height, which a texture rescale cannot fold into DH’s [0, 1] heights.
  • The third layer (g_tColor3, g_tHeight3, g_flBlendSoftness3, 17 materials on de_ancient) is dropped with the rest of the layer.

Source 2 multiplies a surface’s albedo by mix(1, placementTint * g_vColorTint, g_flModelTintAmount), through the tint mask’s red where the material has F_TINT_MASK, and not at all under F_NOTINT. The placement tint is the aggregate mesh’s m_vTintColor or the static object’s. Every color is sRGB, and a product of sRGB colors is the sRGB of the product of their linear values, so the import multiplies them as stored.

Without a tint mask, the material keeps its own share, mix(1, g_vColorTint, g_flModelTintAmount), as its tint, and each placement’s vertices carry the rest as COLOR_0: the whole divided by the material’s share, taken to linear light (Source2Surface.PlacementFactor). The two multiply back to the whole, so every placement of a material shares one slot and one dh.material, however many tints it is placed with. A material with F_PAINT_VERTEX_COLORS multiplies its own vertex color in as well.

A tint mask is a weight per texel, which a vertex color cannot follow, so a tint-masked material keeps the variant path: each tint it is placed with is a slot of its own, mapped to a variant materials/<path>.tint-<rrggbb>.dh-mat that shares the base material’s textures, and whose tint is a decal with no texture, blending multiply with the whole tint as its color and the mask as its mask. Its vertex paint, which Source 2 sends through the mask too, is dropped with vertexPaint. On de_nuke the variants are more than the renderer’s decal table holds (126 blocks): 69 of them draw without their decal, so untinted, the same before and after the tint moved onto the vertices (the area signs at the parking ramp are among them).

Measured: de_nuke ties 5,925 of its 8,555 placements (5,914 aggregate meshes and 11 static objects) to 148 tints, de_dust2 1,030 of 3,610 to 344, de_ancient 278 of 4,815 to 69. The slots each map draws under, before and after the tint moved onto the vertices:

MapSlots beforeSlots nowTinted variants beforeTinted variants nowMaterials tinted by their verticesTint-masked materials
de_nuke50342529719514472
de_dust266551243326220953
de_ancient29619111001871

The slots include the render-order variants of overlays (5 on de_nuke, 3 on de_dust2). Carrying the colors costs vertex data instead: the GLBs grew from 171 to 181 MB on de_nuke, 192 to 209 MB on de_dust2 and 362 to 431 MB on de_ancient, whose layered surfaces all carry COLOR_1, so the piece split counts the sets a surface carries.

csgo_static_overlay and csgo_unlitgeneric state their blend as one F_BLEND_MODE combo; every other shader states it in F_ALPHA_TEST, F_TRANSLUCENT and F_ADDITIVE_BLEND, each later flag winning. A blend that is not an alpha one is the material’s blendMode over its own color texture: additive is additive (lit or unlit as its shader is), mod2x mod2x and multiply multiply, both unlit. g_flOpacityScale is the tint’s alpha. Mod-then-add scales the frame by color + 1 - alpha, which is a multiply of the color divided by its alpha (in linear light, the texture written as <material>.color.png); that is exact wherever the color is no brighter than the alpha, and where it would brighten the frame the surface leaves it as it is (modThenAdd warning; one material on de_ancient, train_shipping_crate_decals_01).

An overlay draws with a depthBias of its render order plus one step, so the lowest still wins against the surface it lies on and a higher order wins against a lower one. The render order belongs to the placement, not the material, so a material placed at several orders (de_nuke’s floor arrows at 0 and 2) is one variant per order.

csgo_effects surfaces are left out. The steam on de_nuke is a constant white color shaped by two panning dust masks, a fresnel fade at grazing angles and depth feathering where it meets geometry. Drawn without those it is a flat fan.

What DH would need to draw these surfaces the way Source 2 does:

  • ✅ a depth bias per material, which Source 2 gives every overlay: depthBias.
  • ✅ additive and multiplying blend modes: blendMode additive, multiply and mod2x.
  • ✅ texture animation: uvScroll and flipbook. The caustics on de_nuke play their 60-frame flipbook. A panning that a dynamic expression drives (aztec_water_cascades_02 on de_ancient) is not evaluated.
  • a fresnel fade and depth feathering for effect cards.

A water shader becomes the same flat water block the Source importer writes for a Water VMT, built by one helper both importers call (ImportedWater in Content): waveScale 0, no foam or caustics unless the shader states some, a gray transmittanceColor of 5% of the light at atDistance, totalInternalReflection: false (the underside is the refracted world), and fogColor drawn unlit as stated. The shader’s normal map is the material’s normal channel, read alone (normalMix 1, normalStrength the Source importer’s 5 times the shader’s own strength), so the water has no procedural chop of its own where a map is bound. The engine does the rest from the block: a surface drawn under a water material is a water body the mover swims in, the buoyancy samplers float props on and the camera fogs from, the same as a Source one, and the closed volumes Hammer builds (top, bottom and sides) are the body’s boundary. Nothing marks it in the map.

ShaderfogColoratDistanceNormal maprefractionScale, reflectionScaleAlso
csgo_waterg_vWaterFogColorg_flWaterDepth inchesg_tNormal, a tile the map’s width times 0.25 units a texel, as Source’sg_flRefractionAmount, g_flReflectionAmount
csgo_water_fancyg_vWaterFogColorg_flWaterMaxDepth inchesg_tWavesNormalHeight, a tile the map’s UV extent (g_vMapUVMax less g_vMapUVMin) over g_vWaveScale, tilted by g_flWavesNormalStrength0 when F_REFRACTION is off, else 1g_flFoamMax times FoamWidthMetersPerCoverage as foamWidthMeters with g_vFoamColor; F_CAUSTICS leaves the caustics at the engine’s own
s&box water_simpleTextureColor times g_flTintColor; none when whiteFrom Translucency: OpaqueWaterMeters at 0 to ClearWaterMeters at 1g_tNormal, a tile the map’s width times 0.25 units a texel, tilted by NormalScaleRefractionNormalScale, 1
s&box construct_waterg_vUnderFogColorg_fUnderFogMaxDepth inchesNone: the shader’s ripples are procedural, so the engine’s own chop playsRefractionNormalScale, RelectionNormalScale (spelled so in the material)

CS2’s older water normals (dev/water_normal_mks) are DXT5nm maps: the x is in the alpha and the red is a constant. The normal conversion swizzles any map whose red is constant and whose alpha varies, rebuilding the blue; read as an ordinary normal map, such a texture loses its x and tilts the water along one axis only, which drew as huge sweeping bands.

Each water material raises one water warning, counted in the report, naming what the block cannot hold:

  • csgo_water_fancy: the decay color and strength, the depth fog’s strength, the debris textures, the foam texture and edge shaping, interaction effects, screen-space reflections and the waves’ height parallax.
  • csgo_water: the fog start (g_flWaterStart, as $fogstart is not read), the fresnel switch and the animated normals (F_ANIMATED_NORMALS).
  • water_simple: the big waves, the metalness and the roughness.
  • construct_water: the ripples and big waves, the foam, the trash layer, and the distance fog.

The numbers that stand in for what the shaders do not state are import defaults, not measurements: a foam band’s width per unit of coverage (FoamWidthMetersPerCoverage, 0.15 m) and how far an s&box water is seen through (OpaqueWaterMeters, 0.1 m, to ClearWaterMeters, 20 m). g_flWaterMaxDepth as the distance at which the body is spent, and the wave texture’s tile as the UV extent over g_vWaveScale, are read from the parameters’ names and have not been compared with a CS2 render. hr_river_water_001 on de_nuke is a csgo_lightmappedgeneric with a water physics surface, not a water shader, and stays an ordinary lit material.

Measured: de_nuke, de_ancient and de_inferno each have one csgo_water_fancy (de_nuke’s liquids/nuke_water, with no foam and a flat 85-inch depth), cs_construct has dev/dev_water2 (csgo_water, 400 inches) and s&box’s Construct has water/construct_water; every one maps to water.

MapMaterialsConvertedLayeredUntexturedLeft out (csgo_effects)TexturesSize
de_ancient19219194015881.94 GB
de_dust227627260047990.97 GB
de_nuke265264601 (the steam)9390.38 GB

The textures include each layered material’s second layer, which is why de_ancient’s grew by about half a gigabyte when the second layers came in.

What is an effect, an overlay or a projection rather than an ordinary surface, by material (and triangles):

Shader and blendde_nukede_dust2de_ancientWhat the import does
csgo_static_overlay 0, opaque2 (156)Opaque, depth bias
csgo_static_overlay 1, translucent6 (462)1 (521)1 (4,539)Blend, depth bias
csgo_static_overlay 2, alpha test1 (6)Mask, depth bias
csgo_static_overlay 3, mod2x (tire tracks, grime, floor arrows, caustics)8 (24,346)1 (9)mod2x, depth bias; the caustics play their flipbook
csgo_static_overlay 6, mod-then-add1 (6,462)multiply of the unpremultiplied color, depth bias
csgo_lightmappedgeneric with F_OVERLAY (paint, signs)9 (2,298)11 (4,782)1 (66)Blend, depth bias
csgo_complex with F_OVERLAY2 (459)Blend, depth bias
csgo_vertexlitgeneric with a depth bias1 (41,946)Opaque, depth bias
csgo_complex with F_ADDITIVE_BLEND (the candle flames, a 25-cell flipbook)1 (616)additive, lit, flipbook
csgo_unlitgeneric 4, additive1 (901)additive, unlit
csgo_effects (steam, dust)1 (120)4 (852)1 (18)Left out
csgo_glass5A glass block: GlassTintColor (or GlassMaskColor) tints it and TextureRoughness roughens it; the dust and tint textures are dropped
csgo_water_fancy11 (473)A water block; see Water

Textures dominate the import: de_ancient takes about six and a half minutes, de_dust2 about four and a half, almost all PNG encoding.

GameMountTexturesModelsGeometry
Counter-Strike 2✅✅✅✅
Deadlock✅✅✅✅
s&box✅🟡 a build newer than the pinned library fails on some files✅ (older vmesh_c layout)✅
Half-Life: Alyx, Dota 2❔❔❔❔

Valve changes these layouts with its patches. A file the pinned library does not know yet fails with a NotSupportedException naming the file and the unrecognized block, never a crash further in.

PlatformImporter
Windows desktop✅
Linux and macOS desktop❔

The importer is portable C#, and SkiaSharp’s native assets ship for every desktop. Only Windows has run it.