Architecture
One-line summary
At plugin bootstrap, reflectively replace Blocks.REDSTONE_WIRE.redstoneController
(a private field, abstract type since 1.21.2) with a DispatchingController
that consults a per-chunk flag and delegates either to the captured vanilla
controller or to a port of SpaceWalkerRS’ Alternate-Current WireHandler.
Why this hook, in one paragraph
Mojang refactored RedstoneWireBlock in 1.21.2 so the actual algorithm
lives behind RedstoneController (abstract: update, calculateWirePowerAt,
getStrongPowerAt, getWirePowerAt) with DefaultRedstoneController as the
vanilla impl. Replacing one private-field reference is the smallest possible
NMS surface for our needs: no registry surgery, no BlockState.owner cache
problem, no Mixin loader requirement, no -javaagent flag for operators.
Paper itself already used the same pattern internally to switch between
vanilla / eigencraft / alternate-current (PR #7701) — we generalise it
per-chunk.
Module layout
plugin/
src/main/java/net/ekaii/redstone/region/
PluginMain.java # JavaPlugin entrypoint (onEnable / onDisable)
Bootstrap.java # PluginBootstrap — does the field swap
config/
ChunkRegistry.java # per-(Level,ChunkPos) RedstoneMode storage
RedstoneMode.java # enum { VANILLA, ALTERNATE_CURRENT }
ChunkPdcCodec.java # serialise/deserialise from PersistentDataContainer
nms/
DispatchingController.java # extends RedstoneController, dispatches
ControllerSwap.java # reflective installation
ac/
AcRedstoneController.java # implements RedstoneController via WireHandler
WireHandler.java # ported from SpaceWalkerRS (MIT)
WireNode.java # ported
Node.java # ported
WireConnection.java # ported
PriorityQueue.java # ported
SimpleQueue.java # ported
UpdateOrder.java # ported
LevelHelper.java # AC <-> Mojang Level adapter
Config.java # AC algorithm flags
cmd/
RedstoneRegionCommand.java # Brigadier command tree
Permissions.java
util/
RegionLog.java # rate-limited logger
ChunkKey.java # Long-packed (x,z) key
test-plugin/
src/main/java/net/ekaii/redstone/test/
TestPluginMain.java
contraptions/
Contraption.java # interface: build(Location), expectedOutputs(tick)
DustGrid.java # 32x32 dust + lever + lamp matrix
RepeaterClock.java # 4-tick clock
LongLine.java # 100-block dust line
Comparator.java
BudSwitch.java
PistonExtender.java
SrLatch.java
ParityRunner.java # builds same contraption in vanilla + AC chunks, asserts parity
PerfBenchmark.java # microbench AC vs vanilla
JunitXmlWriter.java # writes test-results/junit.xml
test-harness/
Dockerfile.folia
entrypoint.sh
docker-compose.yml
run-tests.sh # build everything, start server, wait for results, tear down
Lifecycle
paper-plugin.ymldeclaresbootstrapper: net.ekaii.redstone.region.Bootstrapandmain: net.ekaii.redstone.region.PluginMain.folia-supported: true.Bootstrap.bootstrap(BootstrapContext ctx)— runs before world load:- Resolve
RedstoneWireBlock wire = (RedstoneWireBlock) BuiltInRegistries.BLOCK.get(ResourceLocation.parse("minecraft:redstone_wire")); Field f = RedstoneWireBlock.class.getDeclaredField("redstoneController");f.setAccessible(true);RedstoneController vanilla = (RedstoneController) f.get(wire);f.set(wire, new DispatchingController(wire, vanilla, ChunkRegistry.SINGLETON));- The field is final-effective but not declared
final; Mojang usesset-once in the constructor. We useVarHandlewith release-fence to be safe.
- Resolve
PluginMain.onEnable()— runs at normal plugin enable:- Instantiate
ChunkRegistry(singleton already referenced by the controller). - Hook chunk-load event: read PDC entry → populate
ChunkRegistry. - Hook chunk-unload event: persist current mode → PDC.
- Register
/redstone-regioncommand via the Paper Lifecycle API (LifecycleEvents.COMMANDS).
- Instantiate
PluginMain.onDisable()— best-effort: persist all currently-tracked chunks back to PDC. The controller swap is left in place; on next boot it’s a no-op if our plugin is no longer installed (the captured vanilla controller is gc’d because the dispatching controller is gone, but the field still points at it from the previous run? — no, the JVM is fresh).
The dispatcher
public final class DispatchingController extends RedstoneController {
private final RedstoneController vanilla;
private final RedstoneController ac;
private final ChunkRegistry registry;
public DispatchingController(RedstoneWireBlock wire,
RedstoneController vanilla,
ChunkRegistry registry) {
super(wire);
this.vanilla = vanilla;
this.ac = new AcRedstoneController(wire);
this.registry = registry;
}
@Override
public void update(Level level, BlockPos pos, BlockState state,
Orientation orientation, boolean blockAdded) {
controllerFor(level, pos).update(level, pos, state, orientation, blockAdded);
}
@Override public int calculateWirePowerAt(Level level, BlockPos pos) { return controllerFor(level, pos).calculateWirePowerAt(level, pos); }
@Override public int getStrongPowerAt (Level level, BlockPos pos) { return controllerFor(level, pos).getStrongPowerAt (level, pos); }
@Override public int getWirePowerAt (BlockPos pos, BlockState st) { return vanilla.getWirePowerAt(pos, st); /* stateless */ }
private RedstoneController controllerFor(Level level, BlockPos pos) {
return registry.modeOf(level, pos) == RedstoneMode.ALTERNATE_CURRENT ? ac : vanilla;
}
}
The hot path is one ConcurrentHashMap lookup + one Long2ByteMap.get.
Estimated overhead per wire update: ~30 ns. Vanilla AC saves ~10–100 µs per
update on dust-heavy contraptions, so the dispatch overhead is invisible.
ChunkRegistry — thread-safety & storage
final class ChunkRegistry {
static final ChunkRegistry SINGLETON = new ChunkRegistry();
private final Map<ResourceKey<Level>, Long2ByteOpenHashMap> byLevel = new ConcurrentHashMap<>();
private final Map<ResourceKey<Level>, StampedLock> locks = new ConcurrentHashMap<>();
RedstoneMode modeOf(Level level, BlockPos pos) {
var key = level.dimension();
var map = byLevel.get(key);
if (map == null) return RedstoneMode.VANILLA; // default
var lock = locks.get(key);
var stamp = lock.tryOptimisticRead();
long ck = ChunkKey.pack(pos.getX() >> 4, pos.getZ() >> 4);
byte v = map.get(ck);
if (lock.validate(stamp)) return RedstoneMode.fromByte(v);
// fall back to a read lock under contention
stamp = lock.readLock();
try { return RedstoneMode.fromByte(map.get(ck)); }
finally { lock.unlockRead(stamp); }
}
void setMode(Level level, ChunkPos cpos, RedstoneMode mode) { /* write under writeLock */ }
}
byLevelitself isConcurrentHashMap; we lazy-init theLong2ByteOpenHashMapper dimension on first write.- The per-level
StampedLockallows hot-path optimistic reads with no contention (typical contention level in steady state: zero). - Default value (missing key) is
VANILLA = 0.Long2ByteOpenHashMap.defaultReturnValue(0)is set at construction.
Persistence
Per-chunk: Chunk#getPersistentDataContainer().set(KEY_MODE, BYTE, mode.byteValue()).
KEY_MODE = NamespacedKey.fromString("ekaii:redstone_engine").- Loaded on
ChunkLoadEvent(Folia: handler runs on the region thread that owns the chunk, which is what we want); written onChunkUnloadEvent. - For chunks never visited, no PDC entry → defaults to vanilla. Zero overhead.
Commands
/redstone-region <subcommand> via Paper’s LifecycleEvents.COMMANDS Brigadier
registrar. All sub-commands schedule via RegionScheduler.execute so writes
to chunk PDC happen on the owning region thread.
| Sub-command | Args | Behaviour |
|---|---|---|
info |
— | Prints current mode for chunk under sender’s feet |
set |
<vanilla\|alternate-current> |
Set current chunk |
fill |
<radius:int> <mode> |
Set a (2r+1)×(2r+1) chunk square centered on sender |
clear |
<radius:int> |
Reset to vanilla over a region |
list |
[radius] |
Print non-default chunks within radius |
Permission: redstone-region.admin (op by default).
Folia thread-safety contract
Bootstrapruns single-threaded before regions exist → field swap is safe.- The
redstoneControllerfield is read once per call by the wire block; once written at bootstrap it’s never changed. Region threads see the same final reference. ChunkRegistryis fully thread-safe (ConcurrentHashMap+ per-levelStampedLock).- The AC controller’s
updateruns on the region thread that owns the chunk containingpos. Its accesses (level.getBlockState(neighbor),level.setBlock) all stay within ±1 chunk in vanilla terms; Folia routes these to the same region thread. - Cross-region edge cases (a wire chain straddling a region boundary): Folia serializes region merges and pauses both regions during the merge window — vanilla redstone has the same constraint. AC’s algorithm is no more cross-region than vanilla’s, so the constraint is satisfied.
Risks & mitigations
| Risk | Mitigation |
|---|---|
Mojang renames redstoneController field in a future 1.21.x patch |
Pin Mojang field name in a single constant; if reflection fails at boot, log and fall through to vanilla (don’t crash). Add a CI step that builds against latest Folia snapshot daily. |
| Folia issue #334 (cross-thread redstone access on edge wires) | Same exposure as vanilla. We don’t increase it. Document that edge-of-region pathological contraptions might still be flaky. |
| AC algorithm state leakage between calls in the same region | WireHandler is per-controller, but Folia gives us one region thread per call site — re-entrancy is impossible by Folia’s contract. Add a tripwire ThreadLocal to assert. |
BlockState.owner problem for our future wishes |
Not relevant for technique F (we don’t subclass the Block). |
Paper updates RedstoneController’s method signatures (e.g., 1.22) |
Pin to 1.21.11 in paper-plugin.yml; emit a clear error if API mismatch. |
Test strategy (summary)
- Parity: build N contraptions in two side-by-side chunk-aligned regions
(one vanilla, one alternate-current), run both for K=1000 ticks, sample
BlockStateof every wire/output every tick, assert byte-for-byte equality. - Performance: same contraption in two chunks, alternate ticking, measure
System.nanoTime()over the controller call. Target ≥3× speedup on dust-heavy networks. - Stability: run all contraptions for 100 000 ticks under load. Assert no Folia thread-check throws, no exceptions.
- Boundary: contraption straddling a chunk boundary, both halves AC, half AC half vanilla — assert no exceptions even if behaviour differs.
Full details in docs/TEST-PLAN.md (written separately).