Add periodic reconciliation sweep (Phase 7)

Adds a rotating, non-overlapping-batch ReconciliationScheduler (common/,
loader-agnostic) and wires it into the 1.12.2 leaf: a new tick timer
(reconciliationIntervalTicks, mirroring the existing delta-flush timer)
periodically re-reads and resends a bounded slice of currently-loaded
chunks, catching mutations that never fire a block event (world-gen,
other mods writing blocks directly, /fill, etc.).

ChunkAdapter gains loadedChunkKeys(); Forge1122ChunkAdapter implements it
via ChunkProviderServer.getLoadedChunks(). MapperConfig gains
reconciliationChunksPerSweep to bound sweep cost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-09 19:30:32 +02:00
parent 41034356ac
commit 52583f2056
6 changed files with 232 additions and 6 deletions
@@ -10,6 +10,7 @@ import net.minecraft.world.World;
import net.minecraft.world.WorldServer;
import net.minecraft.world.chunk.Chunk;
import net.minecraft.world.chunk.storage.ExtendedBlockStorage;
import net.minecraft.world.gen.ChunkProviderServer;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.util.BlockSnapshot;
import net.minecraftforge.event.world.BlockEvent;
@@ -101,6 +102,15 @@ public class Forge1122ChunkAdapter implements ChunkAdapter {
MinecraftForge.EVENT_BUS.register(new EventHooks(sink));
}
@Override
public List<Long> loadedChunkKeys() {
List<Long> keys = new ArrayList<>();
for (Chunk chunk : ((ChunkProviderServer) world.getChunkProvider()).getLoadedChunks()) {
keys.add((((long) chunk.x) << 32) | (chunk.z & 0xFFFFFFFFL));
}
return keys;
}
/**
* {@code BlockEvent.BreakEvent} fires *before* the block is actually removed (it's
* cancellable), so reading world state synchronously inside that handler would see the
@@ -2,6 +2,7 @@ package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.ChunkAdapter;
import com.octoturge.mcmapper.common.DefaultBackendConnection;
import com.octoturge.mcmapper.common.ReconciliationScheduler;
import com.octoturge.mcmapper.common.config.MapperConfig;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.SectionData;
@@ -28,9 +29,11 @@ import java.util.Set;
/**
* Entry point for the 1.12.2 leaf — the primary/first-implemented target (Enigmatica 2).
* Phase 1: connects to the backend over WS, backfills the overworld's loaded chunks, and
* flushes event-driven column deltas on a timer (no periodic reconciliation sweep yet — see
* plan's Phase 7). Phase 2 adds full-section backfill/flush for 3D mesh rendering. Phase 3 adds
* `/mcmapper link` and the two-way chat bridge (see LinkCommand, Forge1122ChatBridge).
* flushes event-driven column deltas on a timer. Phase 2 adds full-section backfill/flush for
* 3D mesh rendering. Phase 3 adds `/mcmapper link` and the two-way chat bridge (see LinkCommand,
* Forge1122ChatBridge). Phase 7 adds a periodic reconciliation sweep (see {@link
* ReconciliationScheduler}) that catches non-event mutations (world-gen, other mods, `/fill`)
* the event-driven hooks in {@link Forge1122ChunkAdapter} never see.
*/
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
public class MCMapperMod {
@@ -45,7 +48,9 @@ public class MCMapperMod {
private Forge1122ChunkAdapter adapter;
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<>());
private final Set<Long> pendingSectionChunks = Collections.synchronizedSet(new LinkedHashSet<>());
private final ReconciliationScheduler reconciliationScheduler = new ReconciliationScheduler();
private int ticksSinceFlush = 0;
private int ticksSinceReconciliation = 0;
@Mod.EventHandler
public void preInit(FMLPreInitializationEvent event) {
@@ -62,6 +67,14 @@ public class MCMapperMod {
config.deltaFlushIntervalTicks = forgeConfig.getInt("deltaFlushIntervalTicks", "network",
config.deltaFlushIntervalTicks, 1, 20 * 60,
"How often (in ticks) to batch and flush block-change deltas to the backend");
config.reconciliationIntervalTicks = forgeConfig.getInt("reconciliationIntervalTicks", "network",
config.reconciliationIntervalTicks, 20, 20 * 60 * 60,
"How often (in ticks) to run a reconciliation sweep, re-reading and resending a " +
"rotating slice of loaded chunks to catch changes event hooks miss " +
"(world-gen, other mods, /fill, etc.)");
config.reconciliationChunksPerSweep = forgeConfig.getInt("reconciliationChunksPerSweep", "network",
config.reconciliationChunksPerSweep, 1, 5000,
"Max chunks to re-read and resend per reconciliation sweep");
if (forgeConfig.hasChanged()) forgeConfig.save();
}
@@ -107,9 +120,32 @@ public class MCMapperMod {
@SubscribeEvent
public void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
if (++ticksSinceFlush < config.deltaFlushIntervalTicks) return;
ticksSinceFlush = 0;
flush();
if (++ticksSinceFlush >= config.deltaFlushIntervalTicks) {
ticksSinceFlush = 0;
flush();
}
if (++ticksSinceReconciliation >= config.reconciliationIntervalTicks) {
ticksSinceReconciliation = 0;
reconcile();
}
}
private void reconcile() {
List<Long> chunkKeys = reconciliationScheduler.next(adapter.loadedChunkKeys(),
config.reconciliationChunksPerSweep);
if (chunkKeys.isEmpty()) return;
String dimensionId = adapter.getDimensionId();
for (long key : chunkKeys) {
int chunkX = (int) (key >> 32);
int chunkZ = (int) key;
List<DeltaEvent> columns = adapter.readChunk(dimensionId, chunkX, chunkZ);
if (!columns.isEmpty()) connection.sendDeltas(columns);
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
if (!sections.isEmpty()) connection.sendSections(dimensionId, chunkX, chunkZ, sections);
}
}
private void flush() {