Skip to content

Terrain Hooks

Zaldaryon edited this page Aug 28, 2026 · 1 revision

Terrain hooks

GenTerraHost exposes the parts of a terrain column that consumer mods need to change. The host keeps the vanilla stage order and gives each hook the state produced by the earlier stage and earlier registrations.

Hook table

Registration Timing Delegate Main mutable state
RegisterStep0 Once per chunk, before the column loop BorderTaperHook(ChunkContext) ChunkContext.TaperMap
RegisterStep2 Once per column inside the parallel loop BuildOctavesHook(ChunkContext, ref ColumnContext) Landform weights and octave spans
RegisterStep4 Once per column inside the parallel loop VerticalDistortionHook(ChunkContext, ref ColumnContext) ColumnContext.DistY
RegisterStep5 Once per column inside the parallel loop WaterSelectHook(ChunkContext, ref ColumnContext) ColumnContext.WaterBlockId
RegisterStep7 Once per column and Y level ThresholdHook(ChunkContext, ref ColumnContext, int, double) Returned threshold
RegisterStep10 Once per column after block placement PostPlacementHook(ChunkContext, ref ColumnCarvingContext) Blocks, fluids, heightmaps, moddata
RegisterTerrainFinalize Once after every column in a request finishes TerrainFinalizeHook(ChunkContext) Request-wide persistence and summaries

All registrations use the same (modId, order, hook) shape. Register them during StartServerSide, before InitWorldGen freezes the lists.

Step 0: border taper

Step 0 prepares border smoothing from neighboring heightmaps. It runs once per generated chunk column before the main terrain loop. A hook can inspect the chunk context and edit TaperMap when it needs to change the border transition.

Use this step for border policy. Do not use it for per-column noise or block edits.

Step 2: landform weights and octaves

Step 2 runs for each X/Z column. ColumnContext exposes:

  • LandformWeights as a mutable Span<float>
  • OctaveAmplitudes as a mutable Span<double>
  • OctaveThresholds as a mutable Span<double>
  • WorldX, WorldZ, LocalX, and LocalZ

A landform blending hook normally edits LandformWeights and then calls column.RecalculateOctaves(). That method rebuilds the octave spans from the active landform definitions. If the hook edits the octave spans directly, it owns the consistency of that transformation.

private static void BlendRiverLandform(ChunkContext chunk, ref ColumnContext column)
{
    int riverIndex = LandformRegistry.GetIndex("my-mod:riverlandform");
    if (riverIndex < 0)
        return;

    column.LandformWeights[riverIndex] += 0.25f;
    column.RecalculateOctaves();
}

The span is valid only during the callback. Do not store it or pass it to another thread.

Step 4: vertical distortion

Step 4 runs once per column after the host has calculated the column's upheaval and oceanicity. The hook can add or subtract from column.DistY.

UpheavalStrength and Oceanicity are available as read-only derived values. A consumer should add its own effect to the existing DistY rather than replacing an unrelated consumer's value.

private static void AddTectonicLift(ChunkContext chunk, ref ColumnContext column)
{
    column.DistY += column.UpheavalStrength * 0.1f;
}

Step 5: water selection

Step 5 runs once per column before the noise loop. column.WaterBlockId starts with the host's resolved water block and can be changed to another valid block ID. chunk.FreshWaterBlockId, SaltWaterBlockId, and LakeIceBlockId come from the active world configuration.

This is the appropriate step for a river or watershed adapter that changes salt water to fresh water. It is not a block-carving step.

Step 7: per-voxel threshold

Step 7 runs inside the Y loop. The hook receives the current threshold and must return the value to use for that voxel:

private static double LowerThreshold(
    ChunkContext chunk,
    ref ColumnContext column,
    int posY,
    double threshold)
{
    if (column.WorldX < -3 || column.WorldX > 3)
        return threshold;

    return posY < chunk.SeaLevel ? threshold - 0.2 : threshold;
}

This is the hottest extension point. At a 32 by 32 chunk and a 256 block world height it can run roughly 262,144 times for one chunk request. Avoid allocations, I/O, locks, and expensive noise construction here. A threshold modification is subject to the host's normal early-exit behavior. Use Step 10 when the effect must explicitly replace blocks after the terrain pass.

Step 10: post-placement column work

Step 10 runs once per column after the host has resolved solidity and placed blocks. ColumnCarvingContext provides:

  • global and local coordinates
  • all vertical IServerChunk instances
  • BlockData for the bottom vertical chunk
  • GetBlockDataAtY(int y) for a global Y coordinate
  • ChunkIndex3d(int x, int localY, int z)
  • SetFluid(int localX, int globalY, int localZ, int blockId)
  • mutable terrain and rain heightmaps
  • chunk and map-chunk moddata methods

Use local X/Z when calling SetFluid. The helper chooses the correct vertical chunk and handles negative world coordinates through the context's floor-mod calculation.

Terrain finalization

RegisterTerrainFinalize is the request-wide persistence boundary. It runs after all columns and Step 10 hooks have completed. Use it for arrays such as flow vectors or distances that must be written once after parallel generation has finished.

Do not use a Step 10 hook to assume that another column has already completed. Column execution is parallel.

Full terrain adapter

WorldgenLibAPI.RegisterFullTerrainGeneration(
    "my-mod",
    OrderBands.FinalOverrideMin,
    request =>
    {
        GenerateCompleteColumn(request);
        return true;
    });

Return true only after the consumer owns the complete request. Return false when the next terminal adapter or the normal WorldgenLib pass should continue. A full adapter is a migration bridge, not a substitute for decomposable hooks.

Clone this wiki locally