-
-
Notifications
You must be signed in to change notification settings - Fork 0
Research Shared Instances And Batches
Question. FalcoInstance extends Instance is said to be unable to back a SharedInstance, and
Minestom's block batches are said never to reach it. Both were recorded as "not achievable from the
outside". Are they, and does either one matter?
Verdict: split. Shared instances really are walled off by the compiler — but the capability is reachable with a Falco-owned mechanism, and cheaply. Batch integration is a non-issue: the batches already work against a foreign instance, and the one call they skip writes a field that nothing in Minestom can ever read for such an instance. The batch gap that does exist is a different one, and it sits on the chunk, not on the instance.
Established. Everything on this page is either a compiler outcome, a javap reading, a source
sweep, or an observation from a running probe — and each is labelled. There are no timings on this
page at all, which is why it is the most solidly grounded of the research documents: a compile
error either happens or it does not, and nothing about it depends on a machine.
Open. One inference is explicitly not an observation: that nearbyEntitiesByChunkRange with
Target.PLAYERS behaves as the probe's Target.ENTITIES run did. The probe could not create a real
Player without a network connection. It is reasoned from Minestom's own
EntityTrackerImpl.ChunkView.references() and marked in place. Also open by design: whether the
shared-view mechanism should be built at all — the recommendation is not yet, and the reason is
maintenance cost rather than feasibility.
True at. Written 2026-07-31, entered the repository at
eee1cf2; the section on how the batch
light gap was actually closed was added at
a83ed0b and describes shipped code,
unlike the rest of the page. Against Minestom 2026.06.20-26.1.2. The coupling table, the sealing of
EntityTracker, the single reader of lastBlockChangeTime and the protected modifiers were all
re-checked against that jar at ca79507 and all still hold.
Every claim below comes from the sources of Minestom 2026.06.20-26.1.2 (sources jar from the Gradle
cache, 1 454 files) or from probe code compiled and run against the binary jar of the same version.
The probe lives outside the repository and is not checked in. Where something is inference rather
than an observation, it says so.
The four coupling sites named in Research: Instance Container were not taken on
trust. A full sweep for InstanceContainer and SharedInstance across all 1 454 sources produced the
list below. It is longer than four, and one entry is on a type nobody had looked at.
| Site | Kind | Effect on FalcoInstance
|
|---|---|---|
SharedInstance field instanceContainer and its constructor |
typed | compile error |
SharedInstance.getInstanceContainer() |
return type | compile error at the call site |
SharedInstance.areLinked (three instanceof branches plus the copy() branch) |
instanceof |
always false |
InstanceManager.createSharedInstance(InstanceContainer) |
parameter | compile error |
InstanceManager.registerSharedInstance(SharedInstance) |
parameter | compile error |
InstanceManager.unregisterInstance |
instanceof |
chunks are not unloaded — the known leak |
Chunk constructor |
instanceof |
shared-viewer list is permanently List.of()
|
ChunkBatch.updateChunk |
instanceof |
refreshLastBlockChangeTime() skipped |
AbsoluteBlockBatch.apply |
instanceof |
refreshLastBlockChangeTime() skipped |
Player.setInstance via SharedInstance.areLinked
|
static call | the "same chunks" fast path never taken |
EntityTracker.viewable(List<SharedInstance>, int, int) |
parameter | only an empty list can be passed |
AbsoluteBlockBatch.apply, chunk instanceof LightingChunk
|
instanceof |
light is not resent after a batch |
The last row is new. It is not about the instance at all — it is about FalcoChunk, which extends
DynamicChunk, not LightingChunk.
There is also a hard wall that had not been recorded: EntityTracker is sealed.
public sealed interface EntityTracker permits EntityTrackerImplConfirmed with javap -v: PermittedSubclasses: net/minestom/server/instance/EntityTrackerImpl.
Instance holds one in a private final field and hands it out through getEntityTracker(). So the
route "supply an own EntityTracker whose viewable(...) ignores the empty list" — the obvious way
to repair the Chunk constructor from the outside — is closed the same way Palette was.
Not a runtime problem, not a silent divergence: the compiler refuses. Probe code with a foreign
ProbeInstance extends Instance produced exactly three errors and no more:
error: incompatible types: ProbeInstance cannot be converted to InstanceContainer
MinecraftServer.getInstanceManager().createSharedInstance(foreign);
error: incompatible types: ProbeInstance cannot be converted to InstanceContainer
new SharedInstance(UUID.randomUUID(), foreign);
error: incompatible types: ProbeInstance cannot be converted to InstanceContainer
super(UUID.randomUUID(), foreign);
The third line matters: subclassing SharedInstance does not help either, because its only
constructor demands an InstanceContainer for the super call. SharedInstance is not final and
not sealed, and that buys nothing.
InstanceContainer.addSharedInstance is protected on top of that, so even a container that already
existed could not be told about a foreign view from another package.
What SharedInstance actually provides is three things: the same chunk objects, a separate entity
list, and chunk viewers that include the players of the linked views. All three can be built in
net.onelitefeather.falco.instance without touching Minestom.
Same chunks and separate entities — proven. A ProbeSharedView extends Instance that delegates
every chunk and block method to a ProbeInstance registers and runs:
[1] registerInstance(foreign) ok, isRegistered=true
[2] registerInstance(sharedView) ok, isRegistered=true
[3] same chunk object through both instances = true (ProbeChunk)
[4] separate entity trackers = true
InstanceManager.registerInstance guards with Check.stateCondition(instance instanceof SharedInstance, ...), and a Falco view is not a SharedInstance, so the ordinary registration path
is the correct one and is not blocked. The separate entity list is free: Instance gives every
subclass its own EntityTracker.
Viewers — the interesting part. The Chunk constructor computes its viewer set once, in a
private final Viewable viewable field, and a foreign instance always lands in the List.of()
branch. That field cannot be replaced. What can be replaced is the three methods that read it:
Chunk.addViewer, Chunk.removeViewer and Chunk.getViewers are plain public methods, verified
non-final with javap. And every path that notifies a chunk's viewers goes through getViewers() —
Chunk.sendChunk() calls it directly, and Viewable.sendPacketToViewers (which
InstanceContainer.UNSAFE_setBlock, LightingChunk and the unload packet all use) calls it too.
The batch probe confirms the override is on that path in practice:
[9] chunk.getViewers() queried by the batch = true
So FalcoChunk can compute the union itself. Minestom's own EntityTrackerImpl.ChunkView.references()
shows the recipe: collect players from the owning tracker, then from each linked view's tracker, both
via nearbyEntitiesByChunkRange(chunkPosition, ServerFlag.CHUNK_VIEW_DISTANCE, Target.PLAYERS, ...).
Every method in that recipe is public API. A probe registered an entity in the shared view and asked
both trackers from the chunk of the owning instance:
[A] entity in the shared view, seen by the owning instance tracker = 0
[B] entity in the shared view, seen by the shared view tracker = 1
[C] the chunk can therefore build a union of both = 1
The probe used Target.ENTITIES because creating a real Player needs a network connection; with
Target.PLAYERS the same call is what Minestom itself runs. That last step is reasoned from the
source, not measured.
-
SharedInstance.areLinkedstays false. Measured:areLinked(container, stockShared) = true,areLinked(foreign, falcoSharedView) = false. Its only caller isPlayer.setInstance, where it skips re-sending chunks when a player moves between two instances that already share them. Losing it costs one redundant chunk resend per transfer. It is a slowdown, not a defect. Reaching it would need a customPlayersubclass overridingsetInstance— possible, disproportionate. -
Third-party code that tests
instanceof SharedInstancewill not recognise it. Nothing in Minestom does outsideInstanceManagerandareLinked, but plugins might. -
It is not
SharedInstance. Any API that demands that type stays closed. This is a Falco feature with the same benefit, not a compatible implementation.
One new class (a delegating Instance, roughly the shape of Minestom's SharedInstance — about 20
one-line overrides), a CopyOnWriteArrayList of views on FalcoInstance, and a getViewers()
override of about fifteen lines in FalcoChunk. No new dependency, no new hook.
Recommended if shared worlds are actually wanted, otherwise not. The cost is small and the
mechanism is honest, but nothing in Falco needs it today, and every line of it is a line that a
Minestom upgrade can break. Build it when a caller asks for it, and when it is built, cover the
viewer union with a test, because that is the part that silently degrades to "the other players see
nothing" if a future Chunk stops routing through getViewers().
ChunkBatch.updateChunk and AbsoluteBlockBatch.apply do guard the call, and Minestom flags it
itself at both sites:
if (instance instanceof InstanceContainer) {
// FIXME: put method in Instance instead
((InstanceContainer) instance).refreshLastBlockChangeTime();
}That is the whole of the coupling. Everything else a batch does against an instance uses only
Instance and Chunk API: getChunk, lockWriteLock, Chunk.setBlock, chunk.sendChunk(),
scheduleNextTick. Measured against the probe instance:
[7] ChunkBatch on foreign instance -> block = minecraft:diamond_block
[8] lastBlockChangeTime refreshed by the batch = false
[9] chunk.getViewers() queried by the batch = true
[10] control: container lastBlockChangeTime refreshed = true
[11] AbsoluteBlockBatch on foreign instance -> minecraft:emerald_block / minecraft:emerald_block
Blocks land, viewers are refreshed, the callback fires. Only the timestamp is skipped. The control
line shows the same batch against a real InstanceContainer does refresh it, so the difference is
the instanceof and nothing else.
A sweep for lastBlockChangeTime over all 1 454 sources finds it written in the InstanceContainer
constructor, in UNSAFE_setBlock, at the end of generateChunk, in copy() and in
refreshLastBlockChangeTime() — and read in exactly one place: SharedInstance.areLinked
(:157 and :160, the only two call sites of getLastBlockChangeTime() in the whole codebase,
re-checked at ca79507), inside the branch
if (instance1 instanceof InstanceContainer container1 && instance2 instanceof InstanceContainer container2) {
if (container1.getSrcInstance() != null) { ... container1.getLastBlockChangeTime() == container2.getLastBlockChangeTime(); }That branch requires both arguments to be InstanceContainer and one of them to have come from
InstanceContainer.copy(). A FalcoInstance fails the first condition. So even if the batches did
call refreshLastBlockChangeTime() on it, no code in Minestom would ever read the result.
Conclusion: this is not a missing capability, it is a field with no consumer. FalcoInstance
maintains lastBlockChangeTime and exposes getLastBlockChangeTime() / refreshLastBlockChangeTime();
that is dead weight unless Falco grows its own reader. Building a Falco-owned batch type to close it
would be work spent producing a number nobody looks at.
Recommendation: either drop the timestamp from the public surface of FalcoInstance, or keep it and
document that it is instance-local and deliberately not fed by Minestom's batches. Do not build a
batch type for it.
AbsoluteBlockBatch.apply ends with
for (Chunk chunk : expanded) {
if (chunk instanceof LightingChunk dc) {
dc.sendLighting();
}
}FalcoChunk extends DynamicChunk, so after an AbsoluteBlockBatch the surrounding chunks never
resend their light. This is a chunk-type coupling and has nothing to do with the instance. Two ways
out, both inside Falco: make FalcoChunk extend LightingChunk, or have a Falco batch type resend
light itself. This is an assessment from reading the source — it was not measured, because the branch
only runs after a tick and the probe does not tick the instance.
Both options this section named assume the batch has to be the one that resends. It does not, and the
gap was closed from the chunk instead, by FalcoLightingChunk and LightUpdateAware in
falco-light.
A batch writes its blocks through Chunk#setBlock. A FalcoLightingChunk marks itself and its eight
neighbours on every such write, so the light pass of the following tick covers the whole touched
region and delivers it through onLightUpdated(), which sends an UpdateLightPacket to the viewers
of the chunk. The instanceof LightingChunk branch at the end of apply is simply never needed:
what it would have triggered happens one tick later on a path that does not ask what type the chunk
is.
Two differences from what Minestom's branch would have done, one in each direction:
-
The light arrives one tick later.
sendLighting()insideapplyis immediate; a scheduled pass is not. For a batch that is what a batch is for, this is the correct trade — the alternative is computing light on the thread that applied the blocks. - It arrives for the ring around the batch as well. Minestom's loop walks the chunks the batch touched and no others, so a lamp placed at the edge of the outermost touched chunk does not reach the chunk beyond it. Marking the 3×3 per changed chunk covers that.
This does not change the analysis above. AbsoluteBlockBatch.apply still tests for LightingChunk
and still skips every other type, so the row in the table stays true and a FalcoChunk — which is a
plain DynamicChunk and knows nothing about light — is still not resent by a batch. What changed is
that Falco no longer needs that branch to work. Point 7 of What Minestom would have to change asked
for exactly this hook upstream, protected void onLightingChanged() on Chunk; LightUpdateAware is
the same idea built where it could be built, outside the server.
While the batch probe was failing, the cause turned out to be LightingChunk, not the batch:
protected void onLoad() { doneInit = true; }
public boolean isLoaded() { return super.isLoaded() && doneInit; }A freshly constructed LightingChunk reports isLoaded() == false until onLoad() runs, and
onLoad() is protected. Measured: fresh DynamicChunk.isLoaded = true, fresh LightingChunk.isLoaded = false. Both batches begin with if (!chunk.isLoaded()) { LOGGER.warn(...); return; } — so against an instance that never calls onLoad(), a batch silently does nothing and
logs a warning about an "unloaded chunk". Calling the hook fixed the probe. This is precisely what
FalcoChunk.markLoaded() exists for, and it is a good argument for keeping it.
-
Reflection /
setAccessible. Would openSharedInstance.instanceContainerandInstanceContainer.addSharedInstance, but theinstanceof InstanceContainerchecks inChunk,InstanceManagerand the batches are type tests, not access checks — reflection cannot make them pass. It would buy a half-workingSharedInstanceand a module-opens requirement, and it breaks silently on every Minestom upgrade. -
Faking the type (a class in
net.minestom.server.instance, a proxy, an agent). Theinstanceofchecks would pass, but the code behind them reads private container fields, so the result is either aNullPointerExceptionor the container's behaviour rather than Falco's. It also puts Falco classes into Minestom's package, which is where theprotectedbarrier stops being visible and the next upgrade stops being reviewable. - Forking Minestom. Out of scope by definition, and it turns every upgrade into a merge.
The Falco-owned mechanism in section 1 is preferred over all of these for one reason: it is made of public API only, so an upgrade that breaks it breaks it at compile time.
Both problems have the same root: InstanceContainer carries two pieces of state that belong to
Instance. A single change fixes the shared-instance wall, the batch FIXME and the chunk
constructor at once.
Proposed change — move the shared-instance registry and the block-change timestamp up to Instance.
-
Instancegainsprivate final List<SharedInstance> sharedInstances = new CopyOnWriteArrayList<>();-
public List<SharedInstance> getSharedInstances(),public boolean hasSharedInstances(), and a package-private/protectedaddSharedInstance(SharedInstance); -
private volatile long lastBlockChangeTime;withpublic long getLastBlockChangeTime()andpublic void refreshLastBlockChangeTime().
InstanceContainerloses the same members and inherits them. Source-compatible for callers, since the members keep their names and are inherited. -
SharedInstance: field and constructor parameter retypedInstanceContainer→Instance. Addpublic Instance getSourceInstance()and deprecategetInstanceContainer(). Widening the return type of the existing method would break callers that assign toInstanceContainer, which is why it should be a new method rather than a retyped one. -
InstanceManager.createSharedInstance(InstanceContainer)→createSharedInstance(Instance). Source-compatible for every caller; binary-incompatible, so it needs a recompile — worth naming in the changelog.registerSharedInstanceneeds no signature change oncegetSourceInstance()returnsInstance. -
Chunkconstructor: replacefinal List<SharedInstance> shared = instance instanceof InstanceContainer instanceContainer ? instanceContainer.getSharedInstances() : List.of();
with
final List<SharedInstance> shared = instance.getSharedInstances();. -
ChunkBatch.updateChunkandAbsoluteBlockBatch.apply: replace the guarded cast withinstance.refreshLastBlockChangeTime();. This is what the// FIXME: put method in Instance insteadat both sites already asks for. -
SharedInstance.areLinked: branch onSharedInstanceand onInstancerather than onInstanceContainer. Thecopy()branch needs agetSrcInstance()onInstance(defaultnull) to move with the timestamp; without it, keep that branch onInstanceContainerand only widen the first three. -
Independently:
InstanceManager.unregisterInstanceshould unload chunks for everyInstance, not only for anInstanceContainer(already argued in Research: Instance Container), andAbsoluteBlockBatch'schunk instanceof LightingChunkwould be better as a no-op hook onChunk— sayprotected void onLightingChanged()— so a custom chunk can react.
Impact on existing users. Points 1, 4, 5 and 7 are invisible. Point 3 requires a recompile.
Point 2 is the only one with a deprecation. Nobody loses behaviour: an InstanceContainer keeps
doing exactly what it does now, because it is an Instance and the state simply moved one level up.
Points 1–5 are a self-contained pull request; 6 and 7 can be separate issues.
Minestom 2026.06.20-26.1.2, sources jar from the Gradle cache, all re-checked at ca79507:
-
instance/SharedInstance.java:21-26(theInstanceContainerfield and constructor),:143,:145,:154(theareLinkedbranches),:157,:160(the only readers ofgetLastBlockChangeTime()). -
instance/InstanceContainer.java:94(the field),:578(protected addSharedInstance),:622(the getter). -
instance/InstanceManager.java:38-39(theregisterInstanceguard),:98and:82(createSharedInstance/registerSharedInstance),:121(theunregisterInstancebranch). -
instance/Chunk.java:74(the viewer list),:284-298(addViewer/removeViewer/getViewers, public and non-final),:266-268(isLoaded),:273(protected onLoad),:308(protected unload). -
instance/EntityTracker.java:24—public sealed interface EntityTracker permits EntityTrackerImpl. -
instance/batch/ChunkBatch.java:235andinstance/batch/AbsoluteBlockBatch.java:144— the two guardedrefreshLastBlockChangeTime()calls, both carrying Minestom's own// FIXME: put method in Instance instead.
Falco: FalcoChunk
and FalcoLightingChunk
— the two chunk types the last two sections turn on.
Related pages: the coupling sites this sweep extended are introduced in Research: Instance
Container; why falco-instance refuses what it refuses is in
Rationale: Instances and Chunks; the exclusivity between
FalcoInstance and FalcoLightingChunk, and a way out of it, are in Instance
Performance.