Skip to content

Data-Driven Erosion#32

Open
Amakazor wants to merge 10 commits into
milkucha:1.21.1-fabricfrom
Amakazor:1.21+1.21.1
Open

Data-Driven Erosion#32
Amakazor wants to merge 10 commits into
milkucha:1.21.1-fabricfrom
Amakazor:1.21+1.21.1

Conversation

@Amakazor

@Amakazor Amakazor commented May 20, 2026

Copy link
Copy Markdown

Foreword

I was experimenting with creating a custom instance of TRMT for my own use, based on data-driven principles to allow for quick and painless creation of compat datapack between TRMT, BYG, Regions Unexplored and so on. It works well enough, that I decided, that it might be somewhat useful for you to take a look at this approach.

I have read the Contributor License Agreement in CLA.md and I agree to its terms

Summary

Erosion and de-erosion are now described by datapack JSON rules. Forward erosion rules form a directed acyclic graph where each edge transforms one blockstate into another. De-erosion walks that graph backwards by restoring recorded blockstate history, so convergent paths can reverse correctly without guessing where a block came from.

The system supports:

  • datapack-loaded forward erosion rules
  • datapack-loaded de-erosion rules
  • per-rule thresholds
  • config gates
  • operation pipelines
  • block identifiers and block tags
  • generic blockstate properties
  • reversible history snapshots
  • natural manager-driven de-erosion
  • item-triggered de-erosion
  • DAG cycle detection and debug output

This makes compatibility mostly a data problem: another mod or datapack can add new erosion and de-erosion edges under its own namespace.

Mental Model

Think of erosion transforms as a directed acyclic graph.

Each forward erosion rule describes one edge:

source blockstate -> target blockstate

For example:

minecraft:grass_block -> trmt:eroded_grass_block -> trmt:eroded_dirt

Multiple sources may converge on the same target:

minecraft:dirt        -> trmt:eroded_dirt
minecraft:grass_block -> trmt:eroded_grass_block -> trmt:eroded_dirt

The system records the exact previous blockstate before each forward transform. If both paths reach trmt:eroded_dirt, de-erosion can still restore the correct previous state for each individual block.

Forward traversal is data-driven by erosion_transforms:

tracked traffic reaches threshold -> operations produce the next state -> current state is pushed into history

Backward traversal is data-driven by deerosion_transforms:

natural timeout or configured item trigger -> pop previous state from history -> restore it

If history is empty, a de-erosion rule may use its fallback. Fallbacks are for manually placed or legacy states. Normal erosion and de-erosion should follow recorded history.

Cycles are rejected. If a loaded transform graph contains a cycle, erosion transforms are disabled and the DAG issue is reported.

File Locations

Forward erosion rules:

data/<namespace>/trmt/erosion_transforms/*.json

De-erosion rules:

data/<namespace>/trmt/deerosion_transforms/*.json

Files contain a JSON array of rule objects. Rules are loaded from all namespaces, so a compat mod can add files under its own namespace:

data/some_mod/trmt/erosion_transforms/example.json
data/some_mod/trmt/deerosion_transforms/example.json

Matching Blocks

Use identifier for one block:

{
  "identifier": "minecraft:dirt"
}

Use identifiers for multiple blocks or tags:

{
  "identifiers": [
    "minecraft:short_grass",
    "minecraft:tall_grass",
    "#minecraft:flowers"
  ]
}

Identifiers should always be namespaced. Tags use a leading #.

Forward Erosion Rules

A forward erosion rule has a source matcher, a threshold, and an ordered operation pipeline.

{
  "identifier": "minecraft:grass_block",
  "threshold": {
    "min": 2.0,
    "max": 4.0
  },
  "operations": [
    {
      "name": "requires_config",
      "key": "erosion.grassEnabled"
    },
    {
      "name": "next_state",
      "id": "trmt:eroded_grass_block",
      "properties": {
        "stage": "0"
      },
      "property_sources": {
        "facing": "position"
      }
    },
    {
      "name": "apply_state"
    }
  ]
}

threshold controls how much traffic a tracked position needs before the rule transforms. A random value is chosen between min and max when the position is tracked.

operations run in order. An operation may stop the pipeline by returning no result.

Forward Operations

Supported operations:

requires_config
requires_air
next_stage
next_state
clear_if_no_state
stop_tracking
apply_state
break_block

requires_config checks one config key:

{ "name": "requires_config", "key": "erosion.dirtEnabled" }

The config key must be one of the supported config paths. Unknown keys are rejected during rule evaluation.

requires_air currently supports side: "top":

{ "name": "requires_air", "side": "top" }

next_stage increments an integer blockstate property named stage up to max:

{ "name": "next_stage", "max": 4 }

If the current stage is already at max, the operation leaves no proposed next state. This is commonly followed by another next_state operation.

next_state proposes a new blockstate:

{
  "name": "next_state",
  "id": "minecraft:stone_brick_stairs",
  "properties": {
    "waterlogged": "true"
  },
  "property_sources": {
    "facing": "position"
  }
}

clear_if_no_state clears or cools down tracking when no new state has been proposed.

stop_tracking prevents the next cooldown entry from being written.

apply_state writes the proposed state to the world and records reversible history.

break_block destructively breaks the matched block:

{ "name": "break_block", "drop_chance": 0.1 }

Vegetation and leaves currently use destructive erosion. They do not need reversible history.

Blockstate Properties

Use properties for fixed target values:

"properties": {
  "stage": "0",
  "waterlogged": "false"
}

Use property_sources for runtime-derived values:

"property_sources": {
  "facing": "carry"
}

Supported property sources:

position - derives horizontal facing from position-based rotation
carry    - copies the same-named property from the source state

All property values are strings and must match the target block's property names and allowed values. Invalid properties or invalid values reject the rule during loading.

Reversible History

Forward erosion records the full current blockstate before applying the next state. A history snapshot contains:

  • block id
  • all saved blockstate properties

This is intentionally generic. It is not limited to stage or facing, and it can restore custom properties such as:

{
  "age": "3",
  "waterlogged": "false",
  "variant": "mossy"
}

History does not store block entities or block entity NBT.

De-Erosion Rules

A de-erosion rule describes how a block can move backwards through history, how long natural de-erosion waits, which config gates apply, and what fallback to use if history is empty.

{
  "identifier": "trmt:eroded_sand",
  "natural_config": "deErosion.sandEnabled",
  "item_triggers": {
    "minecraft:brush": {
      "config": "bonemealDeErosion.sandEnabled",
      "mode": "hold",
      "ticks": 20,
      "damage": 1,
      "world_event": 2005
    }
  },
  "timeout_days_by_property": {
    "stage": {
      "0": 3.0,
      "1": 5.0,
      "2": 8.0,
      "3": 13.0,
      "4": 13.0
    }
  },
  "fallback": {
    "id": "minecraft:sand"
  }
}

De-erosion is history-first:

history exists -> pop and restore the previous blockstate
history empty + fallback exists -> use fallback
history empty + no fallback -> do nothing

natural_config controls manager-driven natural de-erosion. Omit it when natural de-erosion should not have a config gate. Unknown config keys are rejected during rule evaluation.

Natural de-erosion scans tracked loaded positions every 200 server ticks. It does not scan the whole world for matching blocks.

De-Erosion Timeouts

Use timeout_days when all states share one timeout:

{
  "timeout_days": 8.0
}

Use timeout_days_by_property when a property controls the timeout:

{
  "timeout_days_by_property": {
    "stage": {
      "0": 3.0,
      "1": 5.0,
      "2": 8.0,
      "3": 13.0,
      "4": 13.0
    }
  }
}

If the block is isolated, the existing isolation timeout reduction still applies.

Item-Triggered De-Erosion

item_triggers controls which items can manually de-erode a matching block. Item-triggered de-erosion uses the same rule lookup and history restoration as natural de-erosion, but it ignores the natural timeout.

"item_triggers": {
  "minecraft:bone_meal": {
    "config": "bonemealDeErosion.grassEnabled",
    "mode": "instant",
    "consume": 1,
    "world_event": 2005
  },
  "minecraft:brush": {
    "config": "bonemealDeErosion.sandEnabled",
    "mode": "hold",
    "ticks": 20,
    "damage": 1,
    "world_event": 2005
  }
}

The config field is optional. If present, the config key must be enabled for the item to work. Omit it when the item should not have a config gate. Unknown config keys are rejected during rule evaluation.

mode: "instant" runs from normal block interaction, such as right-clicking with bone meal.

mode: "hold" runs while the item is in Minecraft's active-use ticking, so it fits brush-like items. It does not make arbitrary tools holdable by itself; the item still needs to enter active use.

ticks is used by hold triggers and controls how long active use must continue before a step-back is attempted.

consume, damage, and world_event describe item side effects after a successful step-back.

Arbitrary Item Examples

A consumable item can repair one history step on right-click:

"item_triggers": {
  "minecraft:clay_ball": {
    "mode": "instant",
    "consume": 1,
    "world_event": 2005
  }
}

A durability tool can repair one history step on right-click and lose durability after success:

"item_triggers": {
  "minecraft:iron_shovel": {
    "mode": "instant",
    "damage": 1,
    "world_event": 2005
  }
}

An active-use item can repair one history step after being held on the block:

"item_triggers": {
  "some_mod:restoration_brush": {
    "config": "bonemealDeErosion.sandEnabled",
    "mode": "hold",
    "ticks": 40,
    "damage": 1,
    "world_event": 2005
  }
}

Fallback States

fallback is used only when history is empty:

"fallback": {
  "id": "trmt:eroded_dirt",
  "properties": {
    "stage": "3"
  },
  "property_sources": {
    "facing": "carry"
  }
}

Fallbacks are useful for manually placed blocks and old tracked entries that do not have history.

Entity Erosion Flow

Player, mob, and vehicle erosion now share the same erosion logic path.

Living entities provide an erosion multiplier. Entity-level vehicle logic can add passenger erosion by summing passenger multipliers. This keeps player walking, mob walking, leashed mobs, and vehicles on the same transform pipeline.

The important consequence for data rules is that the source of traffic no longer needs special transform code. Once a block position reaches its threshold, the data-driven rule decides what happens next.

Debugging

Use the debug UI to inspect tracked erosion progress and timeout information.

Use the DAG command to print the loaded transform graph:

/trmt erosion-dag

If a cycle is found, erosion transforms are disabled and the command reports the cycle. Players joining after reload also receive a warning message when the loaded graph is cyclic.

Default Data

The default data files cover the existing built-in behavior:

  • grass erosion
  • dirt erosion
  • sand erosion
  • leaves erosion
  • vegetation erosion
  • grass de-erosion
  • dirt de-erosion
  • sand de-erosion
  • bone meal restoration for natural blocks
  • brush restoration for sand

These defaults are examples of the public data shape as well as the mod's built-in behavior.

Compatibility Workflow

To add a new compat path:

  1. Add a forward transform JSON file under data/<namespace>/trmt/erosion_transforms/.
  2. Match the source block with identifier, identifiers, or a tag.
  3. Define a threshold.
  4. Add operation pipeline steps that produce and apply the next state.
  5. Add a de-erosion transform under data/<namespace>/trmt/deerosion_transforms/ if the target should naturally or manually reverse.
  6. Use item triggers if an item should manually restore the block.
  7. Run /reload or restart the world, then inspect /trmt erosion-dag.

For a simple one-way destructive transform, use break_block and no de-erosion rule.

For a reversible transform, make sure the forward path eventually uses apply_state, because that is what records history.

Intentional Limits

Reversible history stores blockstates, not block entities or block entity NBT.

Natural de-erosion is manager-driven and only scans positions already tracked by the erosion manager. Existing world blocks that never went through the erosion pipeline are not globally scanned.

Datapacks can add rules, but they cannot register new blocks at reload time. Staged visual transitions for arbitrary block pairs still require existing registered intermediate blocks.

Hold item triggers only work for items that enter active-use ticking.

Conflict handling is currently simple: loaded rules are evaluated in resource order and the first matching forward rule applies.

@milkucha

Copy link
Copy Markdown
Owner

hi! this looks interesting. I haven't had the chance yet to look into it in detail and assess wheter this is more performant that the current implementation, but if so I'd definitely consider a merge since I do want to add seamless compat with mods that add blocks. that's something that's still a few items below in the roadmap so will take some time, but i'll keep this in mind when I cross that bridge. thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants