Add a voxel block type
This page adds one block end to end, then strips the steps back into a checklist you can run yourself. The reference edits to diff alongside it are Malachite Ore (id 30), Limestone (id 31), and Clay (id 32) — the three most recent blocks, each of which touched every file below.
What you will build
Section titled “What you will build”Gypsum — block id 34. A soft, pale evaporite (CaSO₄·2H₂O) that precipitates as sulfate-rich water dries. It generates as a shallow deposit in arid biomes, interbedded with Rock Salt, and is pickaxe-mined. One mined block is meant to charge a vessel with CaSO₄·2H₂O, but the substance is not in the chemistry catalogue yet, so this page leaves composition empty and defers it to Add a scientific route.
Gypsum is a real rock-forming mineral, so it satisfies the natural-deposits rule: world-gen may only spawn materials that occur as real geological deposits — ore minerals, native metals, coal, sulfur, evaporites, clay — never a refined or manufactured material.
Where this sits
Section titled “Where this sits”VoxelSandbox.Dataowns the block’s identity (BlockDefinition,BlockCatalog) and the generation palette (GenerationBlockPalette).VoxelSandbox.Worldowns generation: the deposit field (OreDistributionMath) and the two mirrored resolvers —TerrainGenerator(managed) andTerrainGenerationJob(Burst).- The editor composer (
GameplaySceneComposer, inVoxelSandbox.VoxelWorkshop.Editor) registers content and builds one terrain material per palette slot. - Two texture generators produce the look:
BlockTextureSkinGenerator(C#, runs in the Editor — this is what the player sees) andTools/TextureGen/voxeltex/recipes.py(Python, the batch baker). - EditMode tests under
Tests/EditMode/World/prove the deposit is reachable and the palette stays ordered.
Data must never reference World. Every edit here flows one way: Data → World → editor tooling. See Assemblies & boundaries.
flowchart TD CD["GameplaySceneComposer\nContentDefinitions + TintSlotNames"] BD["BlockDefinition\nid, name, tool, composition"] BC["BlockCatalog\nvalidated registry"] PAL["GenerationBlockPalette\nid + fallback tint + KnownTints"] PARAM["TerrainGenerationParameters\nblittable ushort snapshot"] ORE["OreDistributionMath\nOreDepositKind + Profiles[]"] MAN["TerrainGenerator\nResolveDeepOreBlock (managed)"] BUR["TerrainGenerationJob\nResolveDeepOreBlockId (Burst)"] TEX["BlockTextureSkinGenerator + recipes.py\nalbedo / normal / mask"] MAT["Terrain material per slot\nchunk mesh submesh"] CD --> BD --> BC CD --> PAL PAL --> PARAM PAL --> ORE PARAM --> BUR ORE --> MAN ORE --> BUR MAN -. must stay identical .-> BUR PAL --> TEX --> MAT BUR --> MAT
Before you start
Section titled “Before you start”Read these Docs/ sections:
Docs/MASTERPLAN.md§36 — the chemistry-block bridge (BlockDefinition.Composition).Docs/MASTERPLAN.md§39.7–39.8 — rarity-driven deposits and the §39.8 “Band” model (what must be reachable near spawn).Docs/GROUND_RULES.md— repo boundary and commit rules.- The natural-deposits rule (above): only real geological materials generate.
Intake decisions for Gypsum:
| Decision | Choice | Why |
|---|---|---|
| Real geological material? | yes | a rock-forming evaporite mineral |
| Generation path | deposit (checklist path D1) | it forms lodes, not surface strata |
| Tool / render / solid | Pickaxe / Opaque / solid | a soft rock |
| Yields a substance? | deferred | no SubstanceDefinition for CaSO₄·2H₂O yet |
Validation: the Block Library and Scene Composer modules of the Voxel Workshop confirm registration; Stream Monitor (or a Chunk Lab scan) confirms generation.
The build, step by step
Section titled “The build, step by step”1. Register the content — GameplaySceneComposer.cs
Section titled “1. Register the content — GameplaySceneComposer.cs”Append to ContentDefinitions[] (GameplaySceneComposer.cs:169):
new(32, "Clay"), // … new(33, "Oil", isSolid: false),+ new(34, "Gypsum", requiredTool: ToolType.Pickaxe)The ContentDefinition constructor (GameplaySceneComposer.cs:978) is (int id, string name, bool isSolid = true, BlockRenderClass renderClass = Opaque, bool isBlock = true, ToolType requiredTool = None, ToolType itemToolType = None). On the next compose, LoadOrCreateBlockDefinition (GameplaySceneComposer.cs:211) creates Assets/_Game/Data/Blocks/Definitions/Gypsum.asset and adds it to BlockCatalog.asset automatically.
Append "Gypsum" to TerrainTintSlotNames[] (GameplaySceneComposer.cs:46):
- …, "MalachiteOre", "Limestone", "Clay", "Oil" };+ …, "MalachiteOre", "Limestone", "Clay", "Oil", "Gypsum" };This array’s order must match GenerationBlockPalette.KnownTints exactly — the composer builds one terrain material per entry, in order, and binds it to that slot index.
2. Composition — deferred
Section titled “2. Composition — deferred”BlockDefinition (BlockDefinition.cs:13) carries a composition list (:52) set through SetComposition(...) (:95). The composer does not set it — composition is currently authored only on the .asset in the Inspector or in tests. CaSO₄·2H₂O is not in the chemistry catalogue yet, so leave composition empty; wire it when you author the substance in Add a scientific route.
BlockCatalog.ValidateCatalog() (BlockCatalog.cs:98) already rejects a composition entry with no substance or a non-positive molesPerBlock, so a half-authored composition fails in the Workshop, not in the game.
3. Palette slot and fallback tint — GenerationBlockPalette.cs
Section titled “3. Palette slot and fallback tint — GenerationBlockPalette.cs”Six edits, each mirroring the clayId / oilId pattern. All positions are after the oilId / oilTint / Oil entries.
// 1. serialized id — after oilId (GenerationBlockPalette.cs:100)[SerializeField, Min(BlockId.FirstContentValue)]private int gypsumId = 34;
// 2. fallback tint — after oilTint (:191). Pale sulfate white, slightly warm.[SerializeField]private Color gypsumTint = new(0.90f, 0.88f, 0.84f);
// 3–4. accessors — after Oil (:255) and OilTint (:315)public BlockId Gypsum => new((ushort)gypsumId);public Color GypsumTint => gypsumTint;
// 5. KnownTints — appended after (Oil, oilTint) (:364) (Oil, oilTint),+ (Gypsum, gypsumTint)
// 6. Validate() — after oilId clamp (:409)gypsumId = ClampBlockId(gypsumId);Cite: id fields at GenerationBlockPalette.cs:96, KnownTints at :334, Validate() at :379.
The tint (0.90, 0.88, 0.84) is the albedo already recorded for gypsum in Tools/TextureGen/voxeltex/optics.py (CaSO4·2H2O) — reuse it so the preview and the baked texture agree. (evidence: inferred — matched to the optics table)
4. Burst parameter snapshot — TerrainGenerationParameters.cs
Section titled “4. Burst parameter snapshot — TerrainGenerationParameters.cs”TerrainGenerationParameters (:13) is a blittable struct with a run of ushort block-id fields. Two edits:
// in the block-id block — after OilId (TerrainGenerationParameters.cs:91)public ushort OilId;+ public ushort GypsumId;
// in FromSettings(...) — after OilId assignment (:315)OilId = blocks.Oil.Value,+ GypsumId = blocks.Gypsum.Value,5. Deposit field — OreDistributionMath.cs
Section titled “5. Deposit field — OreDistributionMath.cs”OreDepositKind (OreDistributionMath.cs:11) is ordered common → rare and holds only real geological deposits. Gypsum is an evaporite sibling of RockSalt:
Limestone, // sedimentary CaCO3 …- Clay // fine sediment …+ Clay, // fine sediment …+ Gypsum // CaSO4·2H2O — an evaporite; shallow, arid, beside the haliteProfiles[] (:126) — row order is priority: an overlapping column resolves to the earlier row. Gypsum and halite co-occur and neither is “more valuable”, so place Gypsum immediately after RockSalt (:147), copying its calibration:
new(OreDepositKind.RockSalt, unchecked((int)0x5D19C2E3), 8, 5, 1, 44, 68, 0.66f, 0.45f, 0.055f),+ new(OreDepositKind.Gypsum, unchecked((int)0x6A3F1B27), 8, 5, 1, 42, 66, 0.67f, 0.45f, 0.052f), new(OreDepositKind.Limestone, unchecked((int)0x2A6D9F13), 9, 4, 1, 50, 78, 0.68f, 0.00f, 0.060f),The DepositProfile constructor (:83) fields, in order: kind, seedSalt, coreCellSize, fractureCellSize, floorHeight, peakHeight, ceilingHeight, baseThreshold, exoticnessAffinity, outlierChance. Rarer deposits have a lower ceilingHeight / peakHeight, a higher exoticnessAffinity (0 = biome-agnostic, ~0.45 = wants arid), a lower outlierChance, and a higher baseThreshold. Gypsum here is a hair rarer and shallower than halite — a design choice, not a datum. seedSalt is any distinctive unused hex constant.
6. The two resolvers — managed and Burst, identical
Section titled “6. The two resolvers — managed and Burst, identical”TerrainGenerator.ResolveDeepOreBlock (TerrainGenerator.cs:332) — managed:
OreDepositKind.RockSalt => settings.Blocks.RockSalt,+ OreDepositKind.Gypsum => settings.Blocks.Gypsum, OreDepositKind.Limestone => settings.Blocks.Limestone,TerrainGenerationJob.ResolveDeepOreBlockId (TerrainGenerationJob.cs:222) — Burst, its doc comment reads “Byte-for-byte equivalent of TerrainGenerator.ResolveDeepOreBlock” (:221):
OreDepositKind.RockSalt => Parameters.RockSaltId,+ OreDepositKind.Gypsum => Parameters.GypsumId, OreDepositKind.Limestone => Parameters.LimestoneId,Same case, same position, in both switches. If they diverge, editor previews and streamed/reloaded chunks generate different terrain — the exact failure the Burst comment guards against.
7. Texture — the C# skin generator BlockTextureSkinGenerator.cs
Section titled “7. Texture — the C# skin generator BlockTextureSkinGenerator.cs”Append "Gypsum" to SlotNames[] (:31) in the same order as the palette, then add a case to each switch. The values below are illustrative — they follow the RockSalt case (:461, :509, :542) with the pink-halite tint dropped:
// illustrative — new cases, mirroring the RockSalt shape// BaseColor (BlockTextureSkinGenerator.cs:427)case "Gypsum": return moonlit ? new Color(.70f, .74f, .86f) : autumn ? new Color(.84f, .75f, .66f) : new Color(.90f, .88f, .84f);
// PaletteFor (:491) — soft, low-contrast, like RockSalt / Limestonecase "Gypsum": return new Palette(b, 8, 0.75f, 0.62f);
// Recipe (:522) — the RockSalt evaporite shape (:542) without the pink-halite paintcase "Gypsum": LayerBroad(cv, 0.10f, 0x3C1); LayerStrata(cv, 0.09f, 4, 0x3C2); // evaporite bedding LayerGrain(cv, 0.05f, 0x3C3); // satin-spar fibre LayerSpeckle(cv, 0.03f, 0.16f, 0x3C4); for (int i = 0; i < cv.Res * cv.Res; i++) cv.Gloss[i] = Mathf.Max(cv.Gloss[i], 0.30f); break;
// NormalStrength (:875)case "Gypsum": return 1.3f;
// SlotSmoothness (:953) — soft satiny lustrecase "Gypsum": return 0.30f;8. Texture — the Python batch recipe recipes.py
Section titled “8. Texture — the Python batch recipe recipes.py”Add a build function beside _r_rocksalt (recipes.py:172):
# illustrative — mirrors the _r_rocksalt / _r_limestone shapedef _r_gypsum(cv: Canvas): """Gypsum — CaSO4·2H2O evaporite: pale, soft, faint bedding, a satiny fibre grain.""" layer_broad(cv, 0.10, 3) layer_strata(cv, 0.09, period=8, warp=1.3) layer_grain(cv, 0.05, 4) layer_speckle(cv, colour=hex_to_rgb("#f2efe8"), density=0.02, seed_salt=3) layer_speckle(cv, shade_amp=-0.14, density=0.012, seed_salt=7) cv.set_gloss(np.ones((cv.res, cv.res)), 0.30)and a CATALOGUE row (:274) beside RockSalt (:299):
"RockSalt": Recipe("#e7d7cf", "#f2e6df", steps=8, contrast=0.8, gradient=0.65, build=_r_rocksalt),+ "Gypsum": Recipe("#e6dfd4", "#f0ebe1", steps=8, contrast=0.75, gradient=0.62, build=_r_gypsum),The two generators are independent. BlockTextureSkinGenerator is what the player sees after a compose; recipes.py is what TextureFoundryBatch bakes. Miss one and the skins diverge.
9. Tests — Tests/EditMode/World/
Section titled “9. Tests — Tests/EditMode/World/”TerrainGeneratorTests.cs — extend the generator allow-list (:110) after the RockSalt line (:121):
.Or.EqualTo(settings.Blocks.RockSalt)+ .Or.EqualTo(settings.Blocks.Gypsum)and add a reachability test beside LimestoneAndClay_… (:357), mirroring the RockSalt case (:235):
[Test][Timeout(240000)]public void Gypsum_IsReachableInAridBiomes(){ AssertDepositReachableNear(BiomeType.Desert, settings.Blocks.Gypsum, minY: 20, maxY: 66);}AssertDepositReachableNear (:316) scans a ±160 column box near a biome centre and fails if the deposit never appears.
GenerationPaletteTintLookupTests.cs (:11) — appending Gypsum to KnownTints puts it at slot 29 and pushes the extra directional GrassTop slot to 30:
Assert.That(lookup.GetSlotIndex(palette.Oil, VoxelMeshFaceDirection.Up), Is.EqualTo(28));+ Assert.That(lookup.GetSlotIndex(palette.Gypsum, VoxelMeshFaceDirection.Up), Is.EqualTo(29));- Assert.That(lookup.GetSlotIndex(palette.Grass, VoxelMeshFaceDirection.Up), Is.EqualTo(29));+ Assert.That(lookup.GetSlotIndex(palette.Grass, VoxelMeshFaceDirection.Up), Is.EqualTo(30));The SlotCount assertion (:16) is derived from the list lengths, so it needs no edit.
Verify
Section titled “Verify”Run the terrain tests headlessly (path from CLAUDE.md):
cd /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD"/home/soulwax/Unity/Hub/Editor/6000.6.0f1/Editor/Unity" -runTests -batchmode \ -projectPath . -testPlatform EditMode \ -testFilter "VoxelSandbox.Tests.TerrainGeneratorTests" \ -testResults ./Logs/gypsum.xml -logFile ./Logs/gypsum.logThen, in the open Editor — Tools ▸ Voxel Sandbox ▸ Voxel Workshop:
- Scene Composer ▸ Compose Gameplay — no catalog issues reported;
Gypsum.assetexists and is inBlockCatalog.asset. - Block Library — Gypsum is listed, id 34, with its pale fallback swatch.
- Texture Foundry ▸ regenerate the active skin — the Gypsum tile renders pale with faint banding, not magenta.
- Stream Monitor or a Chunk Lab scan of a desert seed — Gypsum blocks appear in the Y 42–66 band, next to Rock Salt.
“Done” = a green EditMode run plus Gypsum visible in the world with its baked tile.
Now do your own
Section titled “Now do your own”- Name it, take the next free id, confirm it is a real geological material (
MASTERPLAN.md§39.7 + the natural-deposits rule). A refined or manufactured material never generates. GameplaySceneComposer— oneContentDefinitions[]entry (setisSolid/renderClass/requiredTool/isBlockas needed) and oneTerrainTintSlotNames[]entry, in the same order you will use inKnownTints.GenerationBlockPalette— id, fallback tint, two accessors,KnownTintsentry,Validate()clamp. Same relative position in every list.TerrainGenerationParameters—ushortfield +FromSettingsassignment.- Generation path — pick one:
- deposit →
OreDepositKindvalue + a calibratedProfiles[]row (order = priority) + the case in both resolvers. - surface / strata →
BiomeSurfaceBlock+ResolveSurfaceBlock+BiomeCatalog(checklist path D2). - craft-only → nothing here; optionally a
CraftingRecipe.
- deposit →
- Both texture generators —
BlockTextureSkinGenerator(5 switch cases) andrecipes.py(a build function + aCATALOGUErow). - Tests — the allow-list, a reachability test for a deposit, the palette-ordering assertions.
BlockDefinition.composition— only if aSubstanceDefinitionexists; otherwise defer.CHANGELOG.md+VERSIONbump for the landed feature.
Decisions that vary per block: solid vs non-solid; opaque vs cutout; tool gate; biome and depth band (calibrate against the neighbouring Profiles[] row); rarity → baseThreshold + exoticnessAffinity + ceilingHeight.
Pitfalls
Section titled “Pitfalls”Managed / Burst switch divergence.
Symptom: the editor preview shows the block, but streamed or reloaded chunks show stone (or the reverse).
Cause: one resolver got the new OreDepositKind case, the other did not.
Fix: diff TerrainGenerator.ResolveDeepOreBlock against TerrainGenerationJob.ResolveDeepOreBlockId — they are byte-for-byte siblings by contract.
KnownTints vs TerrainTintSlotNames order mismatch.
Symptom: the block renders with a neighbouring block’s texture.
Cause: the composer binds terrain material n to slot n; the two lists drifted out of sync.
Fix: count to your block in each list — the indices must match. GenerationPaletteTintLookupTests catches a length mismatch, not a same-length swap.
Reusing a released block id.
Symptom: old saves resolve the id to the wrong block.
Cause: block ids are permanent — BlockDefinition.Initialize says released definitions must be deprecated, never renumbered (BlockDefinition.cs:78).
Fix: always take the next unused id.
A “refined” material added as a deposit.
Symptom: a smelted or alloyed material generates in terrain.
Cause: it was added to OreDepositKind / Profiles[].
Fix: deposits are ore minerals, native metals, coal, sulfur, evaporites, clay. If it comes out of a furnace, it is a craft output, not a Profiles[] row.
Only one texture generator updated.
Symptom: the in-Editor skin looks right, but a batch bake (or CI) produces a default/magenta tile — or vice versa.
Cause: BlockTextureSkinGenerator and recipes.py are independent and both need the slot.
Fix: add the block to both.
ProjectSettings.asset churn after -runTests.
Symptom: git status is dirty after a headless run.
Cause: -batchmode toggles the SENTIS_ANALYTICS_ENABLED scripting define.
Fix: git checkout -- ProjectSettings/ProjectSettings.asset before staging.
See also
Section titled “See also”- Assemblies & boundaries — why Data cannot reference World.
- Add a scientific route — author the
SubstanceDefinitionthat fills inBlockDefinition.composition. Docs/MASTERPLAN.md§36, §39.7–39.8;.claude/skills/voxel-add-block/references/integration-checklist.md— the file-by-file source of truth this page is the human form of.
User-contributed notes
Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works:
**bold**,*italic*,`code`, and links.Notes policy
Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked
nofollow.