refactor: port TickScheduler to Java

Converts the scheduler, its two extension classes and PartTickEntry. No
jar in the pack references this class, so the ABI constraint was minimal,
but the reference shape was kept anyway: TickScheduler$ and PartTickEntry
are descriptor-identical including the Scala-style accessor pairs, and the
statics class adds only a private constructor.

TickScheduler$ is the one companion singleton in this migration that is
not a pure forwarder. The object extends WorldExtensionInstantiator and
WorldExtensionManager is handed the instance itself, so the two
instantiator hooks stay on the singleton.

processTicks filtered a ListBuffer with a side-effecting predicate that
fires part callbacks and mutates the entry; the Java version iterates and
rebuilds in the same order, so callback order and the reschedule mutation
are unchanged.

One Scala call site changed: the registration now passes
TickScheduler$.MODULE$. TRandomUpdateTick needed casts because a Scala
trait extending a class is a bare interface in bytecode, the same pattern
already recorded for JIconHitEffects.

Five anonfun classes removed.

Validated with 90 plain-JVM tests, a clean build, and the Java 8 Forge
dedicated-server suite, which runs this code every tick because the
scheduler is registered as a world extension at startup.
This commit is contained in:
Algent committed 2026-08-15 11:38:07 +02:00
1 parent 1b9eb05726
commit 213ff0c8ea
5 files changed
+442 -260

No files matched your search

+56
View File
@@ -740,3 +740,59 @@ removed. The Java loop produces no replacement class.
- Java 8 Forge dedicated-server suite: 5 tests, 0 failures, 0 errors, covering real part registration and
instantiation of `mc_torch` and `mcr_face` plus composite tile generation.
- Ray tracing, rendering and particle paths need a client and stay on the manual checklist.
## 2026-08-14 — TickScheduler Java port
No jar in the pack references this class, so the ABI constraint was minimal, but the reference shape was preserved
anyway for consistency with the other ports.
### Observable behavior
No known divergence. Ticks scheduled while the world is mid-tick are still queued and applied after processing rather
than during it, an existing entry for the same part is still only overridden when going from random to scheduled,
random-update parts still reschedule themselves 800 to 1600 ticks out, chunks are still dropped from the active set
once their list empties, and only non-random entries are still written to chunk NBT.
`processTicks` filtered a `ListBuffer` with a side-effecting predicate that fires the part callbacks and mutates the
entry. The Java version iterates and rebuilds the list in the same order, so callback order and the reschedule
mutation are unchanged.
### The singleton carries behavior here
Unlike the other companion singletons in this migration, `TickScheduler$` is not a pure forwarder. The object extends
`WorldExtensionInstantiator` and `WorldExtensionManager.registerWorldExtension` is handed the instance itself, so the
two instantiator hooks have to live on the singleton. It stays a Java class extending `WorldExtensionInstantiator`
with `MODULE$`, delegating into the statics.
### Supported JVM API
- `TickScheduler$` and `TickScheduler$PartTickEntry` are descriptor-identical to the reference, including
`PartTickEntry`'s Scala-style `time()`, `time_$eq`, `random()` and `random_$eq` accessor pairs and both
constructors.
- `TickScheduler` adds only a private constructor.
- `WorldTickScheduler` and `ChunkTickScheduler` were Scala `private class` and are now package-private static nested
classes, keeping their `TickScheduler$WorldTickScheduler` and `TickScheduler$ChunkTickScheduler` binary names.
### Recompiled Scala consumers
One call site. `WorldExtensionManager.registerWorldExtension(TickScheduler)` passed the object as a value and must now
pass `TickScheduler$.MODULE$`.
`TRandomUpdateTick` is a Scala trait extending `TMultiPart`, so at bytecode level it is a bare interface that does not
extend the class. The Java code casts where the reference relied on Scala's view of the type, the same pattern already
recorded for `JIconHitEffects`.
### Compiler artifacts
Accepted divergence: the five `$$anonfun$` classes under `WorldTickScheduler.postTick` and `ChunkTickScheduler`'s
`processTicks`, `saveData` and `loadData` are removed. The Java loops produce no replacement classes.
### Validation
- Complete plain-JVM suite: 90 tests, 0 failures, 0 errors.
- Clean `spotlessApply checkstyleTest build`: passing.
- Java 8 Forge dedicated-server suite: 5 tests, 0 failures, 0 errors. This exercises the port directly rather than
incidentally: the scheduler is registered as a world extension during startup, so `createWorldExtension`, `load`,
and the `preTick`/`postTick` pair all run for every server tick of the suite.
- Scheduled and random tick firing over time, and the chunk NBT round trip, are not asserted by any test and stay on
the manual checklist.
@@ -0,0 +1,41 @@
package codechicken.multipart;
import net.minecraft.world.World;
import net.minecraft.world.chunk.Chunk;
import codechicken.lib.world.ChunkExtension;
import codechicken.lib.world.WorldExtension;
import codechicken.lib.world.WorldExtensionInstantiator;
/**
* Scala companion singleton. This one is not just a forwarder: WorldExtensionManager registers the instance itself, so
* the instantiator hooks have to live here.
*/
public final class TickScheduler$ extends WorldExtensionInstantiator {
public static final TickScheduler$ MODULE$ = new TickScheduler$();
private TickScheduler$() {}
@Override
public WorldExtension createWorldExtension(World world) {
return TickScheduler.createWorldExtension(world);
}
@Override
public ChunkExtension createChunkExtension(Chunk chunk, WorldExtension world) {
return TickScheduler.createChunkExtension(chunk, world);
}
public void loadRandomTick(TRandomUpdateTick part) {
TickScheduler.loadRandomTick(part);
}
public void scheduleTick(TMultiPart part, int ticks) {
TickScheduler.scheduleTick(part, ticks);
}
public long getSchedulerTime(World world) {
return TickScheduler.getSchedulerTime(world);
}
}
@@ -0,0 +1,344 @@
package codechicken.multipart;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import net.minecraft.nbt.CompressedStreamTools;
import net.minecraft.nbt.NBTTagCompound;
import net.minecraft.nbt.NBTTagList;
import net.minecraft.world.ChunkCoordIntPair;
import net.minecraft.world.ChunkPosition;
import net.minecraft.world.World;
import net.minecraft.world.chunk.Chunk;
import net.minecraft.world.storage.SaveHandler;
import net.minecraftforge.common.DimensionManager;
import codechicken.lib.vec.BlockCoord;
import codechicken.lib.world.ChunkExtension;
import codechicken.lib.world.WorldExtension;
import codechicken.multipart.handler.MultipartProxy;
/**
* Used for scheduling delayed callbacks to parts. Do not use this for redstone applications that require precise
* timing. If 2 parts are both scheduled for an update on the same tick, there is no guarantee which one will update
* first. These parts should not depend on a state of another part that may have changed before/after them.
*/
public final class TickScheduler {
private TickScheduler() {}
public static class PartTickEntry {
private final TMultiPart part;
private long time;
private boolean random;
public PartTickEntry(TMultiPart part, long time, boolean random) {
this.part = part;
this.time = time;
this.random = random;
}
public PartTickEntry(TMultiPart part, int ticks) {
this(part, ticks, false);
}
public TMultiPart part() {
return part;
}
public long time() {
return time;
}
public void time_$eq(long time) {
this.time = time;
}
public boolean random() {
return random;
}
public void random_$eq(boolean random) {
this.random = random;
}
}
static class WorldTickScheduler extends WorldExtension {
private long schedTime = 0L;
private Set<ChunkTickScheduler> tickChunks = new HashSet<>();
private boolean processing = false;
private final List<PartTickEntry> pending = new ArrayList<>();
WorldTickScheduler(World world) {
super(world);
}
void scheduleTick(TMultiPart part, int ticks, boolean random) {
if (processing) {
pending.add(new PartTickEntry(part, schedTime + ticks, random));
} else {
doScheduleTick(part, schedTime + ticks, random);
}
}
void doScheduleTick(TMultiPart part, long time, boolean random) {
if (part.tile() != null) {
((ChunkTickScheduler) getChunkExtension(part.tile().xCoord >> 4, part.tile().zCoord >> 4))
.scheduleTick(part, time, random);
}
}
void loadRandom(TRandomUpdateTick part) {
scheduleTick((TMultiPart) part, nextRandomTick(), true);
}
@Override
public void preTick() {
processing = true;
}
@Override
public void postTick() {
if (!tickChunks.isEmpty()) {
Set<ChunkTickScheduler> remaining = new HashSet<>();
for (ChunkTickScheduler chunk : tickChunks) {
if (chunk.processTicks()) {
remaining.add(chunk);
}
}
tickChunks = remaining;
}
processing = false;
for (PartTickEntry e : pending) {
doScheduleTick(e.part(), e.time(), e.random());
}
pending.clear();
schedTime += 1;
}
File saveDir() {
// Calling DimensionManager.getCurrentSaveRootDirectory too early breaks game saves, we have a world
// reference, use it
if (world.provider.dimensionId == 0) {
return ((SaveHandler) world.getSaveHandler()).getWorldDirectory();
}
return new File(DimensionManager.getCurrentSaveRootDirectory(), world.provider.getSaveFolder());
}
File saveFile() {
return new File(saveDir(), "multipart.dat");
}
@Override
public void load() {
try {
FileInputStream in = new FileInputStream(saveFile());
loadTag(CompressedStreamTools.readCompressed(in));
in.close();
} catch (Exception e) {
// Matches the reference, which swallows any failure to read the saved schedule.
}
loadTag(new NBTTagCompound());
}
void loadTag(NBTTagCompound tag) {
if (tag.hasKey("schedTime")) {
schedTime = tag.getLong("schedTime");
} else {
schedTime = world.getTotalWorldTime();
}
}
NBTTagCompound saveTag() {
NBTTagCompound tag = new NBTTagCompound();
tag.setLong("schedTime", schedTime);
return tag;
}
@Override
public void save() {
try {
File file = saveFile();
if (!file.getParentFile().exists()) {
file.getParentFile().mkdirs();
}
if (!file.exists()) {
file.createNewFile();
}
DataOutputStream dout = new DataOutputStream(new FileOutputStream(file));
CompressedStreamTools.writeCompressed(saveTag(), dout);
dout.close();
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
}
int nextRandomTick() {
return world.rand.nextInt(800) + 800;
}
}
static class ChunkTickScheduler extends ChunkExtension {
private List<PartTickEntry> tickList = new ArrayList<>();
ChunkTickScheduler(Chunk chunk, WorldTickScheduler world) {
super(chunk, world);
}
private WorldTickScheduler scheduler() {
return (WorldTickScheduler) world;
}
long schedTime() {
return scheduler().schedTime;
}
void scheduleTick(TMultiPart part, long time, boolean random) {
for (PartTickEntry e : tickList) {
if (e.part() == part) {
// only override an existing tick if we're going from random to scheduled
if (e.random() && !random) {
e.time_$eq(time);
e.random_$eq(random);
}
return;
}
}
tickList.add(new PartTickEntry(part, time, random));
if (tickList.size() == 1) {
scheduler().tickChunks.add(this);
}
}
int nextRandomTick() {
return scheduler().nextRandomTick();
}
boolean processTicks() {
List<PartTickEntry> kept = new ArrayList<>();
for (PartTickEntry e : tickList) {
if (processTick(e)) {
kept.add(e);
}
}
tickList = kept;
return !tickList.isEmpty();
}
boolean processTick(PartTickEntry e) {
if (e.time() <= schedTime()) {
if (e.part().tile() != null) {
if (e.random() && e.part() instanceof TRandomUpdateTick) {
((TRandomUpdateTick) e.part()).randomUpdate();
} else {
e.part().scheduledTick();
}
if (e.part() instanceof TRandomUpdateTick) {
e.time_$eq(schedTime() + nextRandomTick());
e.random_$eq(true);
return true;
}
}
return false;
}
return true;
}
@Override
public void saveData(NBTTagCompound data) {
NBTTagList tagList = new NBTTagList();
for (PartTickEntry e : tickList) {
TMultiPart part = e.part();
if (part.tile() != null && !e.random()) {
NBTTagCompound tag = new NBTTagCompound();
tag.setShort("pos", (short) MultipartProxy.indexInChunk(new BlockCoord(part.tile())));
tag.setByte("i", (byte) part.tile().jPartList().indexOf(part));
tag.setLong("time", e.time());
tagList.appendTag(tag);
}
}
if (tagList.tagCount() > 0) {
data.setTag("multipartTicks", tagList);
}
}
@Override
public void loadData(NBTTagCompound data) {
tickList.clear();
if (!data.hasKey("multipartTicks")) {
return;
}
NBTTagList tagList = data.getTagList("multipartTicks", 10);
ChunkCoordIntPair cc = new ChunkCoordIntPair(0, 0);
for (int i = 0; i < tagList.tagCount(); i++) {
NBTTagCompound tag = tagList.getCompoundTagAt(i);
BlockCoord pos = MultipartProxy.indexInChunk(cc, tag.getShort("pos"));
Object tile = chunk.chunkTileEntityMap.get(new ChunkPosition(pos.x, pos.y, pos.z));
if (tile instanceof TileMultipart) {
tickList.add(
new PartTickEntry(
((TileMultipart) tile).jPartList().get(tag.getByte("i")),
tag.getLong("time"),
false));
}
}
}
@Override
public void unload() {
if (!tickList.isEmpty()) {
scheduler().tickChunks.remove(this);
}
}
}
public static WorldExtension createWorldExtension(World world) {
return new WorldTickScheduler(world);
}
public static ChunkExtension createChunkExtension(Chunk chunk, WorldExtension world) {
return new ChunkTickScheduler(chunk, (WorldTickScheduler) world);
}
public static WorldExtension getExtension(World world) {
return TickScheduler$.MODULE$.getExtension(world);
}
/** Start random ticking for a part. Should be called from TMultiPart.onWorldJoin. */
public static void loadRandomTick(TRandomUpdateTick part) {
TMultiPart multiPart = (TMultiPart) part;
((WorldTickScheduler) getExtension(multiPart.tile().getWorldObj())).loadRandom(part);
}
/** Schedule a tick for part relative to the current time. */
public static void scheduleTick(TMultiPart part, int ticks) {
((WorldTickScheduler) getExtension(part.tile().getWorldObj())).scheduleTick(part, ticks, false);
}
/**
* Returns the current scheduler time. Like the world time, but unaffected by the time set command and other things
* changing time of day.
*
* @deprecated in favor of world.getTotalWorldTime
*/
@Deprecated
public static long getSchedulerTime(World world) {
return ((WorldTickScheduler) getExtension(world)).schedTime;
}
}
@@ -1,259 +0,0 @@
package codechicken.multipart
import codechicken.lib.world.WorldExtensionInstantiator
import codechicken.lib.world.WorldExtension
import codechicken.lib.world.ChunkExtension
import net.minecraft.world.chunk.Chunk
import net.minecraft.world.World
import net.minecraft.nbt.{NBTTagCompound, NBTTagList, CompressedStreamTools}
import scala.collection.mutable.ListBuffer
import codechicken.lib.vec.BlockCoord
import net.minecraft.world.ChunkCoordIntPair
import net.minecraft.world.ChunkPosition
import scala.collection.mutable.HashSet
import java.io.DataOutputStream
import net.minecraftforge.common.DimensionManager
import java.io.File
import java.io.FileOutputStream
import java.io.FileInputStream
import net.minecraft.world.storage.SaveHandler
/** Used for scheduling delayed callbacks to parts. Do not use this for redstone
* applications that require precise timing. If 2 parts are both scheduled for
* an update on the same tick, there is no guarantee which one will update
* first. These parts should not depend on a state of another part that may
* have changed before/after them.
*/
object TickScheduler extends WorldExtensionInstantiator {
class PartTickEntry(
val part: TMultiPart,
var time: Long,
var random: Boolean
) {
def this(part: TMultiPart, ticks: Int) = this(part, ticks, false)
}
private class WorldTickScheduler(world$ : World)
extends WorldExtension(world$) {
var schedTime = 0L
var tickChunks = HashSet[ChunkTickScheduler]()
private var processing = false
private val pending = ListBuffer[PartTickEntry]()
def scheduleTick(part: TMultiPart, ticks: Int, random: Boolean) {
if (processing)
pending += new PartTickEntry(part, schedTime + ticks, random)
else
_scheduleTick(part, schedTime + ticks, random)
}
def _scheduleTick(part: TMultiPart, time: Long, random: Boolean) {
if (part.tile != null)
getChunkExtension(part.tile.xCoord >> 4, part.tile.zCoord >> 4)
.asInstanceOf[ChunkTickScheduler]
.scheduleTick(part, time, random)
}
def loadRandom(part: TRandomUpdateTick) =
scheduleTick(part, nextRandomTick, true)
override def preTick() {
processing = true
}
override def postTick() {
if (!tickChunks.isEmpty)
tickChunks = tickChunks.filter(_.processTicks())
processing = false
pending.foreach(e => _scheduleTick(e.part, e.time, e.random))
pending.clear()
schedTime += 1
}
def saveDir: File = {
if (
world.provider.dimensionId == 0
) // Calling DimensionManager.getCurrentSaveRootDirectory too early breaks game saves, we have a world reference, use it
return world.getSaveHandler.asInstanceOf[SaveHandler].getWorldDirectory
return new File(
DimensionManager.getCurrentSaveRootDirectory,
world.provider.getSaveFolder
)
}
def saveFile: File = new File(saveDir, "multipart.dat")
override def load() {
try {
val in = new FileInputStream(saveFile)
loadTag(CompressedStreamTools.readCompressed(in))
in.close()
} catch {
case e: Exception =>
}
loadTag(new NBTTagCompound)
}
def loadTag(tag: NBTTagCompound) {
if (tag.hasKey("schedTime"))
schedTime = tag.getLong("schedTime")
else
schedTime = world.getTotalWorldTime
}
def saveTag: NBTTagCompound = {
val tag = new NBTTagCompound
tag.setLong("schedTime", schedTime)
return tag
}
override def save() {
val file = saveFile
if (!file.getParentFile.exists)
file.getParentFile.mkdirs()
if (!file.exists)
file.createNewFile()
val dout = new DataOutputStream(new FileOutputStream(file))
CompressedStreamTools.writeCompressed(saveTag, dout)
dout.close()
}
def nextRandomTick = world.rand.nextInt(800) + 800
}
def createWorldExtension(world: World): WorldExtension =
new WorldTickScheduler(world)
private class ChunkTickScheduler(chunk$ : Chunk, world: WorldTickScheduler)
extends ChunkExtension(chunk$, world) {
import codechicken.multipart.handler.MultipartProxy._
var tickList = ListBuffer[PartTickEntry]()
def schedTime = world.schedTime
def scheduleTick(part: TMultiPart, time: Long, random: Boolean) {
val it = tickList.iterator
while (it.hasNext) {
val e = it.next()
if (e.part == part) {
if (
e.random && !random
) // only override an existing tick if we're going from random->scheduled
{
e.time = time
e.random = random
}
return
}
}
tickList += new PartTickEntry(part, time, random)
if (tickList.size == 1)
world.tickChunks += this
}
def nextRandomTick = world.nextRandomTick
def processTicks(): Boolean = {
tickList = tickList.filter(processTick)
return !tickList.isEmpty
}
def processTick(e: PartTickEntry): Boolean = {
if (e.time <= schedTime) {
if (e.part.tile != null) {
if (e.random && e.part.isInstanceOf[TRandomUpdateTick])
e.part.asInstanceOf[TRandomUpdateTick].randomUpdate()
else
e.part.scheduledTick()
if (e.part.isInstanceOf[TRandomUpdateTick]) {
e.time = schedTime + nextRandomTick
e.random = true
return true
}
}
return false
}
return true
}
override def saveData(data: NBTTagCompound) {
val tagList = new NBTTagList
tickList.foreach { e =>
val part = e.part
if (part.tile != null && !e.random) {
val tag = new NBTTagCompound
tag.setShort("pos", indexInChunk(new BlockCoord(part.tile)).toShort)
tag.setByte("i", part.tile.partList.indexOf(part).toByte)
tag.setLong("time", e.time)
tagList.appendTag(tag)
}
}
if (tagList.tagCount > 0)
data.setTag("multipartTicks", tagList)
}
override def loadData(data: NBTTagCompound) {
tickList.clear()
if (!data.hasKey("multipartTicks"))
return
val tagList = data.getTagList("multipartTicks", 10)
val cc = new ChunkCoordIntPair(0, 0)
for (i <- 0 until tagList.tagCount) {
val tag = tagList.getCompoundTagAt(i)
val pos = indexInChunk(cc, tag.getShort("pos"))
val tile =
chunk.chunkTileEntityMap.get(new ChunkPosition(pos.x, pos.y, pos.z))
if (tile.isInstanceOf[TileMultipart])
tickList += new PartTickEntry(
tile.asInstanceOf[TileMultipart].partList.apply(tag.getByte("i")),
tag.getLong("time"),
false
)
}
}
override def unload() {
if (!tickList.isEmpty)
world.tickChunks -= this
}
}
def createChunkExtension(
chunk: Chunk,
world: WorldExtension
): ChunkExtension =
new ChunkTickScheduler(chunk, world.asInstanceOf[WorldTickScheduler])
/** Start random ticking for a part. Should be called from
* TMultiPart.onWorldJoin
*/
def loadRandomTick(part: TRandomUpdateTick) {
getExtension(part.tile.getWorldObj)
.asInstanceOf[WorldTickScheduler]
.loadRandom(part)
}
/** Schedule a tick for part relative to the current time.
*/
def scheduleTick(part: TMultiPart, ticks: Int) {
getExtension(part.tile.getWorldObj)
.asInstanceOf[WorldTickScheduler]
.scheduleTick(part, ticks, false)
}
/** Returns the current scheduler time. Like the world time, but unaffected by
* the time set command and other things changing time of day. Deprecated in
* favor of world.getTotalWorldTime
*/
@Deprecated
def getSchedulerTime(world: World): Long =
getExtension(world).asInstanceOf[WorldTickScheduler].schedTime
}
@@ -86,7 +86,7 @@ class MultipartProxy_serverImpl {
MultipartSPH
)
WorldExtensionManager.registerWorldExtension(TickScheduler)
WorldExtensionManager.registerWorldExtension(TickScheduler$.MODULE$)
TileChunkLoadHook.init()
MultipartCompatiblity.load()