Files
Iris/docs/api/tree-feller.md
T
Brian Neumann-Fopiano d5a55ccfcf API
2026-07-26 12:12:49 -05:00

22 KiB

Iris tree feller API

art.arcane.iris.api.tree lets another plugin drive the Iris tree feller and charge for it. The feller removes a whole Iris-generated tree, block by block, when a sneaking survival player breaks one of its logs with an axe. This API lets you turn it on for a player who would not otherwise be allowed it, override the durability rules, and take something from that player for each log removed — with a reservation you can get back if the log turns out not to be removable.

There are two things you can do, and they are independent:

You want to… Use
start a felling run that Iris would not have started, or price it IrisTreeFellerService#tryFell with TreeFellerOptions.integrationOverride(...)
avoid double-handling the block breaks Iris generates while felling IrisTreeFellerService#isManagedBreak
ask whether a block belongs to an Iris tree at all IrisTreeFellerService#isTreeBlock

The tree feller is off by default. treeFeller.enabled in Iris's settings is false out of the box, and the standalone path additionally requires the iris.treefeller permission. An INTEGRATION_OVERRIDE request bypasses both — that is what the mode is for, and it means your plugin is now the thing that decides who may fell trees.


Depending on Iris and acquiring the service

See README.md for the build and plugin-descriptor setup. The service is registered with the Bukkit ServicesManager at ServicePriority.Normal.

package com.example.woodcutting;

import art.arcane.iris.api.tree.IrisTreeFellerService;
import org.bukkit.Bukkit;
import org.bukkit.plugin.RegisteredServiceProvider;

public final class FellerAccess {
    private FellerAccess() {
    }

    public static IrisTreeFellerService service() {
        RegisteredServiceProvider<IrisTreeFellerService> provider =
                Bukkit.getServicesManager().getRegistration(IrisTreeFellerService.class);
        return provider == null ? null : provider.getProvider();
    }
}
public interface IrisTreeFellerService {
    boolean tryFell(BlockBreakEvent event, TreeFellerOptions options);

    boolean isManagedBreak(BlockBreakEvent event);

    boolean isTreeBlock(Block block);
}

The lifecycle

your BlockBreakEvent handler
   |
   v
tryFell(event, options)         register a felling request against this break.
   |                            Returns true when YOUR request is pending. Nothing has
   |                            happened yet and no hook has fired.
   |
   |  (Iris re-checks everything at EventPriority.MONITOR)
   v
onActivationAccepted()          the run is real. Fires exactly once, if at all.
   |
   |  (per LOG block, in the order the tree comes apart)
   v
reserveLogCost()   -> false     you refuse. The run ends. Nothing to give back.
   |
   | true
   v
   +--> commitLogCost()         the log is gone. The charge is yours. FINAL.
   +--> refundLogCost()         the log could not be removed. Give it back.

Rules Iris guarantees:

  • onActivationAccepted fires at most once per run, and only after Iris has re-validated the break at MONITOR: the event was not cancelled, the block still resolves to the same Iris tree, and no other run already claims that tree.
  • reserveLogCost is called once per log block, not once per run. A twelve-log tree calls it up to twelve times. Leaves never reserve — they are removed without consulting you.
  • reserveLogCost is called before Iris charges the axe's durability, so a refusal costs the player nothing at all.
  • Exactly one of commitLogCost or refundLogCost follows a reserveLogCost that returned true, with the one exception described under Failure policy.
  • commitLogCost is final. There is no reversal after it, and Iris will not call refundLogCost for a block it has already committed.
  • A reserveLogCost that returns false ends the whole run immediately. It does not skip that log and continue.
  • A tree can only be felled by one run at a time, server-wide. A second player breaking the same tree while a run is in flight has their break cancelled with drops suppressed, and no hook of yours is called for it.

There is no terminal callback. TreeFellerRunHooks has no "the run finished" method. If your accounting needs to know when a run ended, count commitLogCost and refundLogCost calls against the onActivationAccepted that opened the run, and treat a run with no activity as over.


Threading

Three different threads are involved and the distinction matters, because two of them are region threads on Folia and one is an entity scheduler.

Call Thread
tryFell You call it. It must be the thread delivering the BlockBreakEvent — the region thread that owns the broken block
isManagedBreak Any thread. It is a set lookup and touches nothing else
isTreeBlock The region thread that owns the block. It reads block state and can block on disk — see below
onActivationAccepted The region thread that owns the broken block, inline in the MONITOR dispatch
reserveLogCost The feller's entity scheduler thread
commitLogCost The feller's entity scheduler thread
refundLogCost The feller's entity scheduler thread

The three cost hooks run on the player's entity scheduler, which is the thread that owns that player on Folia. Reading and mutating the feller's inventory, experience and effects is legal there. The player's world is not yours on that thread — do not read or write blocks from a cost hook.

onActivationAccepted runs on the block's region thread, inline inside the BlockBreakEvent dispatch at MONITOR. Blocks and the player are both legal to touch there, but you are inside event dispatch: return promptly.

Do not block, in any of the four. No I/O, no CompletableFuture#join, no locks held across the call. Iris does not interrupt a hook that hangs and does not time it out; the contract is the only protection. If a cost decision needs remote data, cache it — prime it on PlayerJoinEvent.

isTreeBlock is the expensive one

isTreeBlock reads Iris's mantle — the generator's persistent per-region metadata store — to find out whether the block was placed by an Iris tree. If the mantle region covering that block is not resident in memory, this call loads it from disk, synchronously, on your thread. It also reads the block's type and block data, so the chunk must be loaded and you must be on the region thread that owns it.

Concretely: on a first touch in a cold area it does a filesystem stat, and possibly a full region load and decompress, before it answers. On a warm area it is a couple of map lookups.

Do not call it per block in a loop, per tick, or on a large area. Nothing else in this API touches the mantle; if you are calling isTreeBlock speculatively rather than about a block a player just interacted with, you are using it wrong.


Worked example: charging stamina per log

A plugin with its own stamina pool. It lets players fell trees regardless of Iris's permission and enabled switch, charges 4 stamina per log, gives it back when a log turns out not to be removable, and preserves the axe 50% of the time.

The hooks

package com.example.woodcutting;

import art.arcane.iris.api.tree.TreeFellerRunHooks;

import java.util.UUID;

public final class StaminaFellHooks implements TreeFellerRunHooks {
    private static final int COST_PER_LOG = 4;

    private final StaminaPool pool;
    private final UUID fellerId;

    public StaminaFellHooks(StaminaPool pool, UUID fellerId) {
        this.pool = pool;
        this.fellerId = fellerId;
    }

    @Override
    public void onActivationAccepted() {
        pool.beginRun(fellerId);
    }

    @Override
    public boolean reserveLogCost() {
        return pool.withdraw(fellerId, COST_PER_LOG);
    }

    @Override
    public void commitLogCost() {
        pool.recordSpend(fellerId, COST_PER_LOG);
    }

    @Override
    public void refundLogCost() {
        pool.deposit(fellerId, COST_PER_LOG);
    }
}

TreeFellerRunHooks declares all four methods and none of them has a default, so an implementation must provide all four even when three are empty. TreeFellerRunHooks.NONE is the shared no-op implementation whose reserveLogCost returns true; use it when you want the override behaviour without a cost.

The hooks instance is per run, not per plugin. Build a new one for each tryFell call and put the feller's identity in it — Iris hands the same instance back for every callback of that run and never inspects it, so it is the natural place to carry run state.

The listener

package com.example.woodcutting;

import art.arcane.iris.api.tree.IrisTreeFellerService;
import art.arcane.iris.api.tree.TreeFellerOptions;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;

public final class WoodcuttingListener implements Listener {
    private static final int PRESERVE_PERCENT = 50;

    private final StaminaPool pool;

    public WoodcuttingListener(StaminaPool pool) {
        this.pool = pool;
    }

    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
    public void onBreak(BlockBreakEvent event) {
        IrisTreeFellerService feller = FellerAccess.service();

        if (feller == null || feller.isManagedBreak(event)) {
            return;
        }

        Player player = event.getPlayer();

        if (!pool.hasWoodcutting(player.getUniqueId())) {
            return;
        }

        TreeFellerOptions options = TreeFellerOptions.integrationOverride(
                PRESERVE_PERCENT, new StaminaFellHooks(pool, player.getUniqueId()));

        feller.tryFell(event, options);
    }
}

The isManagedBreak guard is not optional. While a run is in progress Iris fires a BlockBreakEvent for every block it removes, so that protection plugins and loggers see the removals. Without the guard your listener would call tryFell on Iris's own break events and your stamina check would run once per block of the tree.

EventPriority.HIGH is a deliberate choice, and it is load-bearing. It is after the priorities a protection plugin normally uses to cancel, and — the part that matters — strictly before HIGHEST, which is where Iris asks for its own standalone run. A break is claimed by the first tryFell that succeeds against it, so a handler at HIGHEST or MONITOR can find that Iris has already taken it. See What tryFell actually promises.

Registration is ordinary:

@Override
public void onEnable() {
    getServer().getPluginManager().registerEvents(new WoodcuttingListener(pool), this);
}

The minimum: turn the feller on, charge nothing

If you only want players in your woodcutting class to fell trees, with Iris's own durability behaviour and no cost:

IrisTreeFellerService feller = FellerAccess.service();

if (feller != null && !feller.isManagedBreak(event) && classes.isWoodcutter(event.getPlayer())) {
    feller.tryFell(event, TreeFellerOptions.integrationOverride(0, TreeFellerRunHooks.NONE));
}

Put that in a BlockBreakEvent handler at a priority earlier than HIGHEST — Iris asks for its own standalone run at HIGHEST, and the first request to succeed claims the break.

TreeFellerRunHooks.NONE never refuses and never charges. A durabilityPreservationChance of 0 means every log costs one point of axe durability, which is vanilla-equivalent.

TreeFellerOptions.standalone() exists for completeness — it is the request Iris makes for itself — and there is almost never a reason for a third party to pass it. It respects the enabled switch and the permission, so it can only ever do what Iris would already have done.


What tryFell actually promises

boolean tryFell(BlockBreakEvent event, TreeFellerOptions options);

true means your felling request is pending against this break. It does not mean a tree will fall — Iris re-validates everything at MONITOR and can still drop the request there.

A break is claimed by the first tryFell that succeeds against it. The moment a request is accepted, Iris marks that BlockBreakEvent as managed, and every later tryFell for the same event returns false immediately, without looking at your access at all. There is no displacement and no last-writer-wins: whoever asks first, in event-priority order, owns the break.

State when you call Your access Result
Nothing pending either Your request becomes pending. Returns true
A request already accepted for this break either The existing one stays. Returns false

That has one consequence you must design around. Iris makes its own STANDALONE request from a listener at EventPriority.HIGHEST. If your handler runs at HIGHEST and happens to be registered after Iris's, or at MONITOR, Iris has already claimed the break and your override is refused. Call tryFell from a handler at a priority strictly earlier than HIGHESTLOWEST, LOW, NORMAL or HIGH — and your INTEGRATION_OVERRIDE is the one that lands. HIGH is the usual choice.

Two plugins that both want to override the same break resolve the same way: the earlier priority wins, and the later one gets false and knows it lost. Nothing is silently discarded.

false means no request of yours is pending. Iris returns false when:

  • the service is disabled, or event or options is null;
  • the event is already cancelled;
  • the event is one Iris is already managing — either a break another request has already claimed, or one of the per-block probe events Iris fires during a run. isManagedBreak answers both;
  • canUse failed — for STANDALONE that means treeFeller.enabled is false or the player lacks iris.treefeller; an INTEGRATION_OVERRIDE never fails this check;
  • the break is not a fellable candidate.

true is still not a run. The MONITOR re-validation drops the request if the event was cancelled after you asked, if the block no longer resolves to the same Iris tree, or if another run already claims that tree — and in none of those cases does a hook fire. Open your run state in onActivationAccepted, not at tryFell.

What makes a break a candidate

An INTEGRATION_OVERRIDE bypasses the enabled switch and the permission. It does not bypass any of these, and there is no option to:

  • the player is in GameMode.SURVIVAL;
  • the player is sneaking;
  • the broken block is tagged Tag.LOGS;
  • the item in the player's main hand is an axe;
  • the block carries Iris tree provenance in the mantle — it was placed by an Iris tree, has not been replaced since, and is not part of a structure.

A tree the player planted with a vanilla sapling is not an Iris tree and will never fell. Neither is a log a player placed by hand: Iris clears the provenance record for a block as soon as it is broken or built over.


How a run comes apart

Once activated, Iris discovers the tree by walking the mantle provenance markers outward from the broken block in all 26 directions, breadth-first. Members are then removed in that discovery order — the block the player broke first, then outward — with ties broken by Y, then X, then Z.

Discovery is bounded. If any bound is hit the discovery is incomplete, and Iris falls back to removing only the block the player actually broke:

Bound Value
Members collected 131 072
Positions visited 1 000 000
Distance from the broken block on any axis 256 blocks

Removal is paced: Iris removes a batch of blocks, then yields for a tick before the next batch, so a large tree takes several ticks and does not stall a region. Batch size scales with the tree.

A run ends immediately, with no further hooks, when the player:

  • stops sneaking,
  • changes their held hotbar slot,
  • swaps their hands,
  • goes offline, leaves survival mode, or changes world,
  • breaks their axe (the run ends after the log that broke it is committed),
  • or replaces the axe in that slot with a different item.

Each removed block fires its own BlockBreakEvent, marked so that isManagedBreak returns true for it during dispatch. Other plugins can cancel that event to protect a block. A cancelled probe on a log refunds that log's reservation and ends the run; a cancelled probe on a leaf has no reservation to give back and the run simply carries on to the next member. Drops for each block are computed with the axe as it was before that block's durability charge, so enchantments like Silk Touch and Fortune apply normally.

The original break event is cancelled by Iris with drops and experience suppressed, because Iris delivers them itself per block instead.


What the options carry

public record TreeFellerOptions(
        TreeFellerAccess access,
        int durabilityPreservationChance,
        TreeFellerRunHooks runHooks) {

    public static TreeFellerOptions standalone();

    public static TreeFellerOptions integrationOverride(
            int durabilityPreservationChance,
            TreeFellerRunHooks runHooks);
}

The canonical constructor throws NullPointerException for a null access or runHooks, and IllegalArgumentException for a durabilityPreservationChance outside 0 .. 100. Both factory methods go through it, so TreeFellerOptions.integrationOverride(101, hooks) throws at the call site rather than clamping silently.

durabilityPreservationChance is a percentage: the chance that removing one log costs the axe no durability at all. 0 charges every log; 100 never charges. It is rolled independently per log. An unbreakable axe is never charged whatever the value.

The value is only honoured for INTEGRATION_OVERRIDE. A STANDALONE request ignores whatever you passed and uses treeFeller.durabilityPreservationChance from Iris's settings — TreeFellerOptions.standalone() hard-codes 0 in the record for exactly that reason.

public interface TreeFellerRunHooks {
    TreeFellerRunHooks NONE;

    void onActivationAccepted();

    boolean reserveLogCost();

    void commitLogCost();

    void refundLogCost();
}

Iris never calls anything else on your hooks object — not equals, not hashCode, not toString. It holds the reference for the duration of the run and drops it when the run ends.


Failure policy

Iris assumes a hooks implementation will throw, refuse late, or be handed a player who logs out mid-run.

Misbehaviour What Iris does
onActivationAccepted throws Logged with the stack trace. The run continues — activation is a notification, not a veto
reserveLogCost throws Logged, treated as false. The run ends. Nothing is refunded, because nothing was reserved
reserveLogCost returns false Not a fault. The run ends cleanly at that log
commitLogCost throws Logged. The run ends. The block is already gone and is not restored
refundLogCost throws Logged. The run ends
A hook blocks for a long time Nothing. Iris does not time hooks out, does not warn, and cannot interrupt them
tryFell is passed a null event or options Returns false. No hook is called
Two plugins request an override for one break The one whose handler ran first wins. The other gets false and no hook of its own fires
Resolving the candidate throws Logged. tryFell returns false
isTreeBlock throws Logged. Returns false
Iris is disabled mid-run Every active run is finished immediately. No refund is issued for anything outstanding

Iris does not quarantine a misbehaving integration. There is no fault limit, no disable-after-N, and no automatic unregistration. A hooks implementation that throws on every log will be logged on every log, forever.

The one place a refund can be missed

A refund is delivered by scheduling onto the feller's entity scheduler. If that scheduling fails — the player has logged out, or been removed from the world, between the reservation and the failure that triggers the refund — Iris finishes the run without calling refundLogCost. The same applies to a plugin shutdown that ends runs in flight.

The exposure is at most one log's worth of cost per run, and only in the window between reserving a log and resolving it, which is a single block removal. If a stricter guarantee matters to you, do not settle the charge inside the hooks: accumulate reservations in your own per-run state keyed by the feller, and reconcile on PlayerQuitEvent and on your own onDisable. The hooks tell you what happened; they are not a transaction log you can rely on being complete across a disconnect.


Configuration

plugins/Iris/settings.json:

Key Default Meaning
treeFeller.enabled false Master switch for the standalone path only. When false, Iris never fells a tree on its own. An INTEGRATION_OVERRIDE request is unaffected
treeFeller.durabilityPreservationChance 0 Percentage chance a log costs no axe durability, for the standalone path only. Clamped to 0 .. 100 on read

Permission, declared in the plugin descriptor:

Node Default Meaning
iris.treefeller op Required for the standalone path. An INTEGRATION_OVERRIDE request does not check it

Enum reference

TreeFellerAccess

Constant Enabled switch iris.treefeller durabilityPreservationChance source
STANDALONE Required Required Iris settings; the value in your options is ignored
INTEGRATION_OVERRIDE Bypassed Bypassed The value in your options

Neither mode bypasses the candidate checks — survival, sneaking, an axe, a log, and Iris tree provenance.

Write a default arm when switching over this enum; see README.md.