-
Notifications
You must be signed in to change notification settings - Fork 5
Hopper Optimization
An experimental feature that cuts the tick cost of idle hoppers by letting them sleep while vanilla keeps doing all the real work.
Requires: a Paper/Spigot server it can hook (NMS). No plugin deps. Experimental - off by default.
This is off by default and it pokes at server internals through reflection. It does not re-implement hopper transfers, it just tells vanilla to skip idle ones, but it's still experimental. Test it on a dev server before you touch production. More on that in the warning below.
Vanilla ticks every loaded hopper every tick. The expensive parts are the neighbour scan and the AABB search for dropped items. On a big spread-out server with tens of thousands of hoppers, that dominates the tick even though almost all of those hoppers are sitting idle.
Instead of rewriting transfer logic (which would break sorters, comparators, protection plugins, etc), this just puts idle hoppers to sleep and leaves vanilla as the transfer engine:
- Hot (awake): a small set of hoppers with recent activity. Their transfer cooldown is left at 0 so vanilla ticks them at full speed. Normal transfers, item pickup, sorting, filters, all vanilla.
- Cold (sleeping): everything else. The cooldown gets pinned via reflection so vanilla skips them entirely. A slow round-robin tops that pin back up, so a cold hopper costs about one field write every few seconds instead of a full vanilla tick.
A hopper goes hot when there's work to do, then decays back to cold after AwakeTicks of quiet. Things that wake a hopper (and its neighbours where it makes sense):
- an item drops on it or above it (
ItemSpawnEvent, plus the periodic item sweep for items that land later) - a hopper pulls an item, or vanilla moves items between containers
- a neighbouring container is placed, broken, or edited by a player
- redstone toggles near it
- the hopper itself is placed, or its chunk loads
New hoppers start hot on purpose, so a sorter loaded mid-transfer drains its pending work before it settles cold. It never gets frozen with items stuck in it.
Because vanilla still does every transfer, behaviour matches stock. The only change is idle hoppers stop burning tick time.
Two safety nets, both in the code:
-
Can't hook internals -> stays off. On startup
HopperNMS.bind()tries to grab the server internals it needs. If any lookup throws,bind()returns false, the optimizer logsoptimizer disabled - could not bind to server internalsand never registers its events or task. Vanilla hoppers are left completely alone. On top of that, every cooldown write is verified with a read-back (pinAtchecks the field actually took the value), so a wrong field binding manages zero hoppers instead of duping items. -
Plugin stops -> hoppers self-heal. Sleeping hoppers are only pinned to
SleepTicksof cooldown, and the plugin re-pins them before that drains. If the plugin stops updating them (crash, disable, whatever), vanilla drains the pinned cooldown and every hopper resumes normal behaviour withinSleepTicks. Nothing is left frozen on disk. On a clean unload it also actively resets every managed hopper back to vanilla right away.
Lives under Hoppers.Optimize in config.yml:
Hoppers:
Optimize:
Enabled: false
Permission: hopperopt.use
# Idle hoppers are put to sleep; vanilla still does every transfer/pickup/sort while they're awake.
# ticks a hopper stays awake (full vanilla speed) after its last activity before going back to sleep.
AwakeTicks: 60
# cooldown a sleeping hopper is pinned to. Also the self-heal window: if the plugin stops,
# sleeping hoppers resume normal vanilla behavior within this many ticks. Keep modest.
SleepTicks: 200
# ticks between item-entity sweeps that wake hoppers sitting under dropped items.
# Lower = snappier pickup of items already on the ground, slightly more CPU.
ItemSweepInterval: 10
# items vanilla moves per hopper transfer (vanilla default is 1). Applied per-world via the
# Spigot hopper-amount setting, so it affects every hopper in the world, not just managed ones.
TransferAmount: 1
# worlds to leave on vanilla hoppers (case-insensitive).
DisabledWorlds: []Options:
| Option | Default | What it does |
|---|---|---|
Enabled |
false |
Master toggle. Off by default. When off, nothing hooks and hoppers are pure vanilla. |
AwakeTicks |
60 |
How many ticks a hopper stays hot (full vanilla speed) after its last activity before going back to sleep. Min 1 (20 ticks = 1 second). |
SleepTicks |
200 |
Cooldown a sleeping hopper is pinned to, and the self-heal window if the plugin stops. Keep it modest. Min 20. |
ItemSweepInterval |
10 |
Ticks between item-entity sweeps that wake hoppers sitting under dropped items. Lower is snappier pickup, slightly more CPU. Min 1. |
TransferAmount |
1 |
Items vanilla moves per hopper transfer. See the note below. Min 1. |
DisabledWorlds |
[] |
Worlds to leave on pure vanilla hoppers. Case-insensitive. |
TransferAmount is not a per-managed-hopper setting. It's applied per world through the Spigot hopper-amount runtime value, so it affects every hopper in that world, managed or not. It also resets to your spigot.yml value on a full server restart (the plugin re-applies it each time the world gets picked up). If you don't want to change transfer throughput, leave it at 1.
/hopperopt prints the live status and counters. Permission: hopperopt.use.
If the optimizer is off (disabled in config or the NMS bind failed) it just says so:
[Hoppers] optimizer is OFF (disabled in config or NMS bind failed).
When it's running it reports the managed hopper counts, the current settings, and running counters:
[Hoppers] optimizer ON
managed: 12345 (hot 12, cold 12333)
awake 60t, sleep 200t, sweep 10t, amount 1
wakes: 84213 sweeps: 5120
- managed: total hoppers under management, split into hot (awake) and cold (sleeping)
- the settings line echoes
AwakeTicks,SleepTicks,ItemSweepIntervalandTransferAmount - wakes: how many times a cold hopper has been promoted back to hot
- sweeps: how many item-entity sweeps have run
This feature reaches into server internals and pins a live vanilla field. It's experimental. Do not flip Enabled: true on a production server without testing it on a dev server first. Load your actual builds (sorters, filter systems, big hopper chains, storage setups) and confirm items still flow the way you expect. The fail-safes are designed so a bad hook just falls back to vanilla, but you still want to see it behave on your own setup before you trust it live.
HopperNMS binds by real Mojang-mapped names, so it targets Paper 1.20.5+, which runs Mojang-mapped at runtime (no obfuscation remapping needed). It looks up net.minecraft.core.BlockPos, Level#getBlockEntity, and the hopper's int cooldown field (tries cooldownTime, then transferCooldown). For TransferAmount it also grabs the world's spigotConfig and its hopperAmount field. If any of those lookups fail on your server build, bind() returns false and the optimizer stays off, so you're back to plain vanilla hoppers with no harm done.
Getting started
Features
- Core Features
- Chat
- World and Mob Protections
- Moderation
- Events, Cooldowns and Tasks
- Utilities and Misc
- Hopper Optimization