Skip to content

Commit c921ac3

Browse files
committed
fix(terrain): reconcile chunk tracker with loaded chunks
The tracker's only event sources are the loadChunk/unloadChunk hooks, so any chunk transition that bypasses them (chunk data written to a chunk the tracker never saw load, mods mutating the loaded-chunk map directly) leaves the tracker permanently out of sync: the affected chunks never satisfy the 3x3 neighbor-readiness gate, are never published to the renderer, and produce holes that approaching does not heal until the world is re-entered. ChunkTracker.reconcile diffs the tracked set against the world's authoritative loaded set and replays the difference through the regular event methods, and the client-chunk-manager mixin invokes it once per client tick; in steady state the diff is empty and no events fire.
1 parent b770bc2 commit c921ac3

3 files changed

Lines changed: 186 additions & 0 deletions

File tree

celeritas-common/src/main/java/org/embeddedt/embeddium/impl/render/chunk/map/ChunkTracker.java

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,64 @@ public synchronized void onChunkStatusRemoved(int x, int z, int flags) {
112112
this.updateNeighbors(x, z);
113113
}
114114

115+
/**
116+
* Reconciles the tracked state with the chunks actually loaded by the world.
117+
*
118+
* The normal load/unload event stream can miss transitions (e.g. chunk data written to a
119+
* chunk the tracker never saw load, or mods mutating the world's chunk map directly),
120+
* which leaves the tracker permanently out of sync with no way to recover: affected chunks
121+
* never satisfy the neighbor-readiness gate and are never published to subscribers. This
122+
* method diffs the tracked set against the world's authoritative loaded set and replays
123+
* the difference through the regular event methods, so neighbor-readiness gating and
124+
* subscription notifications behave exactly as if the events had arrived normally.
125+
*
126+
* @param actuallyLoadedChunkKeys packed coordinates of the chunks currently loaded in the
127+
* world; not modified by this method
128+
*/
129+
public synchronized void reconcile(LongSet actuallyLoadedChunkKeys) {
130+
int unknown = this.chunkStatus.defaultReturnValue();
131+
132+
// Collect the diff first: replaying the events mutates chunkStatus, which must not
133+
// happen while either set is being iterated.
134+
LongList missingFromTracker = null;
135+
var loadedIterator = actuallyLoadedChunkKeys.iterator();
136+
while (loadedIterator.hasNext()) {
137+
long key = loadedIterator.nextLong();
138+
if (this.chunkStatus.get(key) == unknown) {
139+
if (missingFromTracker == null) {
140+
missingFromTracker = new LongArrayList();
141+
}
142+
missingFromTracker.add(key);
143+
}
144+
}
145+
146+
LongList missingFromWorld = null;
147+
var trackedIterator = this.chunkStatus.keySet().iterator();
148+
while (trackedIterator.hasNext()) {
149+
long key = trackedIterator.nextLong();
150+
if (!actuallyLoadedChunkKeys.contains(key)) {
151+
if (missingFromWorld == null) {
152+
missingFromWorld = new LongArrayList();
153+
}
154+
missingFromWorld.add(key);
155+
}
156+
}
157+
158+
if (missingFromTracker != null) {
159+
for (int i = 0; i < missingFromTracker.size(); i++) {
160+
long key = missingFromTracker.getLong(i);
161+
this.onChunkStatusAdded(PositionUtil.unpackChunkX(key), PositionUtil.unpackChunkZ(key), ChunkStatus.FLAG_ALL);
162+
}
163+
}
164+
165+
if (missingFromWorld != null) {
166+
for (int i = 0; i < missingFromWorld.size(); i++) {
167+
long key = missingFromWorld.getLong(i);
168+
this.onChunkStatusRemoved(PositionUtil.unpackChunkX(key), PositionUtil.unpackChunkZ(key), ChunkStatus.FLAG_ALL);
169+
}
170+
}
171+
}
172+
115173
private void updateNeighbors(int x, int z) {
116174
int r = this.requiredNeighborRadius;
117175
for (int ox = -r; ox <= r; ox++) {

src/main/java/com/dhj/actinium/mixin/vintage/core/terrain/MixinClientChunkManager.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dhj.actinium.mixin.vintage.core.terrain;
22

3+
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
34
import net.minecraft.client.multiplayer.ChunkProviderClient;
45
import net.minecraft.world.World;
56
import net.minecraft.world.chunk.Chunk;
@@ -19,6 +20,10 @@ public abstract class MixinClientChunkManager {
1920
@Final
2021
private World world;
2122

23+
@Shadow
24+
@Final
25+
private Long2ObjectMap<Chunk> loadedChunks;
26+
2227
@Inject(method = "loadChunk", at = @At("RETURN"))
2328
private void afterLoadChunkFromPacket(int x, int z, CallbackInfoReturnable<Chunk> cir) {
2429
ChunkTrackerHolder.get(this.world).onChunkStatusAdded(x, z, ChunkStatus.FLAG_ALL);
@@ -28,5 +33,14 @@ private void afterLoadChunkFromPacket(int x, int z, CallbackInfoReturnable<Chunk
2833
private void afterUnloadChunk(int x, int z, CallbackInfo ci) {
2934
ChunkTrackerHolder.get(this.world).onChunkStatusRemoved(x, z, ChunkStatus.FLAG_ALL);
3035
}
36+
37+
/**
38+
* Repairs any drift between the chunk tracker and the authoritative loaded set once per
39+
* client tick; in steady state the diff is empty and no events fire.
40+
*/
41+
@Inject(method = "tick", at = @At("RETURN"))
42+
private void reconcileTrackerWithLoadedChunks(CallbackInfoReturnable<Boolean> cir) {
43+
ChunkTrackerHolder.get(this.world).reconcile(this.loadedChunks.keySet());
44+
}
3145
}
3246

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package org.embeddedt.embeddium.impl.render.chunk.map;
2+
3+
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
4+
import it.unimi.dsi.fastutil.longs.LongSet;
5+
import org.embeddedt.embeddium.impl.util.PositionUtil;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
11+
import static org.junit.jupiter.api.Assertions.assertEquals;
12+
import static org.junit.jupiter.api.Assertions.assertTrue;
13+
14+
class ChunkTrackerReconcileTest {
15+
@Test
16+
void worldChunksUnknownToTrackerBecomeReadyOnceNeighborsSatisfyRadius() {
17+
ChunkTracker tracker = new ChunkTracker(1);
18+
ChunkTracker.Subscription subscription = tracker.subscribe();
19+
20+
tracker.reconcile(loadedArea(10, 20));
21+
22+
assertEquals(9, tracker.getReadyChunks().size());
23+
assertEquals(readyEvents("load:", 10, 20), sortedDrain(subscription));
24+
}
25+
26+
@Test
27+
void trackerChunksMissingFromWorldAreRemoved() {
28+
ChunkTracker tracker = new ChunkTracker(1);
29+
ChunkTracker.Subscription subscription = tracker.subscribe();
30+
31+
tracker.reconcile(loadedArea(10, 20));
32+
drain(subscription);
33+
assertEquals(9, tracker.getReadyChunks().size());
34+
35+
tracker.reconcile(new LongOpenHashSet());
36+
37+
assertTrue(tracker.getReadyChunks().isEmpty());
38+
assertEquals(readyEvents("unload:", 10, 20), sortedDrain(subscription));
39+
}
40+
41+
@Test
42+
void reconcileIsIdempotent() {
43+
ChunkTracker tracker = new ChunkTracker(1);
44+
ChunkTracker.Subscription subscription = tracker.subscribe();
45+
46+
LongSet loaded = loadedArea(10, 20);
47+
tracker.reconcile(loaded);
48+
assertEquals(9, drain(subscription).size());
49+
50+
tracker.reconcile(loaded);
51+
tracker.reconcile(loaded);
52+
53+
assertEquals(List.of(), drain(subscription));
54+
assertEquals(9, tracker.getReadyChunks().size());
55+
}
56+
57+
@Test
58+
void isolatedChunkDoesNotBecomeReadyWithoutNeighbors() {
59+
ChunkTracker tracker = new ChunkTracker(1);
60+
ChunkTracker.Subscription subscription = tracker.subscribe();
61+
62+
LongSet isolated = new LongOpenHashSet();
63+
isolated.add(PositionUtil.packChunk(10, 20));
64+
tracker.reconcile(isolated);
65+
66+
assertTrue(tracker.getReadyChunks().isEmpty());
67+
assertEquals(List.of(), drain(subscription));
68+
69+
// The isolated chunk was still registered: once its neighbors appear, readiness resolves normally.
70+
tracker.reconcile(loadedArea(10, 20));
71+
assertEquals(9, tracker.getReadyChunks().size());
72+
assertEquals(9, drain(subscription).size());
73+
}
74+
75+
/**
76+
* A 5x5 square of loaded chunks around the center; with neighbor radius 1 exactly the
77+
* inner 3x3 chunks satisfy the readiness gate.
78+
*/
79+
private static LongSet loadedArea(int centerX, int centerZ) {
80+
LongSet keys = new LongOpenHashSet();
81+
for (int x = centerX - 2; x <= centerX + 2; x++) {
82+
for (int z = centerZ - 2; z <= centerZ + 2; z++) {
83+
keys.add(PositionUtil.packChunk(x, z));
84+
}
85+
}
86+
return keys;
87+
}
88+
89+
private static List<String> readyEvents(String prefix, int centerX, int centerZ) {
90+
List<String> events = new ArrayList<>();
91+
for (int x = centerX - 1; x <= centerX + 1; x++) {
92+
for (int z = centerZ - 1; z <= centerZ + 1; z++) {
93+
events.add(prefix + x + "," + z);
94+
}
95+
}
96+
events.sort(null);
97+
return events;
98+
}
99+
100+
private static List<String> sortedDrain(ChunkTracker.Subscription subscription) {
101+
List<String> events = drain(subscription);
102+
events.sort(null);
103+
return events;
104+
}
105+
106+
private static List<String> drain(ChunkTracker.Subscription subscription) {
107+
List<String> events = new ArrayList<>();
108+
subscription.forEachEvent(
109+
(x, z) -> events.add("load:" + x + "," + z),
110+
(x, z) -> events.add("unload:" + x + "," + z)
111+
);
112+
return events;
113+
}
114+
}

0 commit comments

Comments
 (0)