Skip to content
Edit on GitHub

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.

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.

  • VoxelSandbox.Data owns the block’s identity (BlockDefinition, BlockCatalog) and the generation palette (GenerationBlockPalette).
  • VoxelSandbox.World owns generation: the deposit field (OreDistributionMath) and the two mirrored resolvers — TerrainGenerator (managed) and TerrainGenerationJob (Burst).
  • The editor composer (GameplaySceneComposer, in VoxelSandbox.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) and Tools/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

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.


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.

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 halite

Profiles[] (: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 / Limestone
case "Gypsum": return new Palette(b, 8, 0.75f, 0.62f);
// Recipe (:522) — the RockSalt evaporite shape (:542) without the pink-halite paint
case "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 lustre
case "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 shape
def _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.

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.


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.log

Then, in the open Editor — Tools ▸ Voxel Sandbox ▸ Voxel Workshop:

  1. Scene Composer ▸ Compose Gameplay — no catalog issues reported; Gypsum.asset exists and is in BlockCatalog.asset.
  2. Block Library — Gypsum is listed, id 34, with its pale fallback swatch.
  3. Texture Foundry ▸ regenerate the active skin — the Gypsum tile renders pale with faint banding, not magenta.
  4. 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.

  1. 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.
  2. GameplaySceneComposer — one ContentDefinitions[] entry (set isSolid / renderClass / requiredTool / isBlock as needed) and one TerrainTintSlotNames[] entry, in the same order you will use in KnownTints.
  3. GenerationBlockPalette — id, fallback tint, two accessors, KnownTints entry, Validate() clamp. Same relative position in every list.
  4. TerrainGenerationParameters — ushort field + FromSettings assignment.
  5. Generation path — pick one:
    • deposit → OreDepositKind value + a calibrated Profiles[] row (order = priority) + the case in both resolvers.
    • surface / strata → BiomeSurfaceBlock + ResolveSurfaceBlock + BiomeCatalog (checklist path D2).
    • craft-only → nothing here; optionally a CraftingRecipe.
  6. Both texture generators — BlockTextureSkinGenerator (5 switch cases) and recipes.py (a build function + a CATALOGUE row).
  7. Tests — the allow-list, a reachability test for a deposit, the palette-ordering assertions.
  8. BlockDefinition.composition — only if a SubstanceDefinition exists; otherwise defer.
  9. CHANGELOG.md + VERSION bump 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.

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.

  • Assemblies & boundaries — why Data cannot reference World.
  • Add a scientific route — author the SubstanceDefinition that fills in BlockDefinition.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.

  1. Loading notes…