Phase 2: forge-1_12_2 full chunk section backfill/flush

Extends ChunkAdapter with readSections() (reads non-empty 16x16x16
sections via the chunk's ExtendedBlockStorage array, skipping fully-air
ones for free) and DeltaSink with onChunkDirty(), additive to Phase 1's
column-based readChunk()/onDelta — 2D column tracking is unchanged.
BackendConnection grows sendSections(), base64-encoding each section's
4096-entry char[] (char, not short, since short would overflow for any
blockId >= 2048 — see SectionData's javadoc) into the wire protocol's new
"sections" message, mirrored in MCMapper-Backend's matching commit.

MCMapperMod now tracks dirty chunks (not just dirty columns) and, on the
same flush tick as column deltas, re-reads and resends full section data
for any chunk touched since the last flush — same "current state, not a
diff" approach as columns, at chunk instead of column granularity.

Verified against a live MCMapper-Backend instance: a known half-solid
section sent through this exact code path round-trips to a mesh with
exactly 24 vertices / 36 indices at the backend's mesh-serving endpoint,
matching the worker's greedy-mesher unit tests for a uniform section.
This commit is contained in:
2026-08-08 16:19:32 +02:00
parent 699f09f081
commit 2b7e0e090d
6 changed files with 174 additions and 11 deletions
@@ -2,12 +2,14 @@ package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.ChunkAdapter;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.SectionData;
import net.minecraft.block.Block;
import net.minecraft.block.state.IBlockState;
import net.minecraft.util.math.BlockPos;
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.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.util.BlockSnapshot;
import net.minecraftforge.event.world.BlockEvent;
@@ -17,6 +19,7 @@ import net.minecraftforge.fml.common.gameevent.TickEvent;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -35,6 +38,10 @@ public class Forge1122ChunkAdapter implements ChunkAdapter {
this.dimensionId = String.valueOf(world.provider.getDimension());
}
public String getDimensionId() {
return dimensionId;
}
@Override
public List<DeltaEvent> readChunk(String dimension, int chunkX, int chunkZ) {
Chunk chunk = world.getChunk(chunkX, chunkZ);
@@ -60,6 +67,35 @@ public class Forge1122ChunkAdapter implements ChunkAdapter {
return new DeltaEvent(dimensionId, worldX, topY, worldZ, blockStateId, now, source);
}
@Override
public List<SectionData> readSections(int chunkX, int chunkZ) {
Chunk chunk = world.getChunk(chunkX, chunkZ);
ExtendedBlockStorage[] storage = chunk.getBlockStorageArray();
List<SectionData> sections = new ArrayList<>();
for (int sectionY = 0; sectionY < storage.length; sectionY++) {
ExtendedBlockStorage ebs = storage[sectionY];
// Vanilla leaves a section's storage null when it's entirely air, and
// ExtendedBlockStorage tracks its own non-air block count — both let us skip empty
// sections without a 4096-position scan.
if (ebs == null || ebs.isEmpty()) continue;
char[] blocks = new char[4096];
for (int ly = 0; ly < 16; ly++) {
for (int lz = 0; lz < 16; lz++) {
for (int lx = 0; lx < 16; lx++) {
IBlockState state = ebs.get(lx, ly, lz);
int id = Block.getIdFromBlock(state.getBlock());
int meta = state.getBlock().getMetaFromState(state);
int blockStateId = ((id & 0xFFF) << 4) | (meta & 0xF);
blocks[(ly * 16 + lz) * 16 + lx] = (char) blockStateId;
}
}
}
sections.add(new SectionData(sectionY, blocks));
}
return sections;
}
@Override
public void registerEventHooks(DeltaSink sink) {
MinecraftForge.EVENT_BUS.register(new EventHooks(sink));
@@ -106,12 +142,14 @@ public class Forge1122ChunkAdapter implements ChunkAdapter {
for (DeltaEvent e : readChunk(dimensionId, chunk.x, chunk.z)) {
sink.onDelta(e);
}
sink.onChunkDirty(chunk.x, chunk.z);
}
@SubscribeEvent
public void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END || dirtyColumns.isEmpty()) return;
long now = System.currentTimeMillis();
Set<Long> dirtyChunks = new LinkedHashSet<>();
Iterator<Long> it = dirtyColumns.iterator();
while (it.hasNext()) {
long key = it.next();
@@ -120,6 +158,10 @@ public class Forge1122ChunkAdapter implements ChunkAdapter {
int wz = (int) key;
Chunk chunk = world.getChunk(wx >> 4, wz >> 4);
sink.onDelta(readColumn(chunk, wx, wz, wx & 15, wz & 15, now, DeltaEvent.Source.EVENT));
dirtyChunks.add((((long) (wx >> 4)) << 32) | ((wz >> 4) & 0xFFFFFFFFL));
}
for (long chunkKey : dirtyChunks) {
sink.onChunkDirty((int) (chunkKey >> 32), (int) chunkKey);
}
}
@@ -1,8 +1,10 @@
package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.ChunkAdapter;
import com.octoturge.mcmapper.common.DefaultBackendConnection;
import com.octoturge.mcmapper.common.config.MapperConfig;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.SectionData;
import net.minecraft.world.WorldServer;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.config.Configuration;
@@ -17,13 +19,16 @@ import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
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). Chat bridge and `/mcmapper link` land in Phase 3.
* plan's Phase 7). Phase 2 adds full-section backfill/flush for 3D mesh rendering. Chat bridge
* and `/mcmapper link` land in Phase 3.
*/
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
public class MCMapperMod {
@@ -35,7 +40,9 @@ public class MCMapperMod {
private Configuration forgeConfig;
private final MapperConfig config = new MapperConfig();
private DefaultBackendConnection connection;
private Forge1122ChunkAdapter adapter;
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<>());
private final Set<Long> pendingSectionChunks = Collections.synchronizedSet(new LinkedHashSet<>());
private int ticksSinceFlush = 0;
@Mod.EventHandler
@@ -68,8 +75,18 @@ public class MCMapperMod {
LOGGER.info("MCMapper (1.12.2 leaf) connecting to " + config.backendUrl);
WorldServer overworld = event.getServer().getWorld(0);
Forge1122ChunkAdapter adapter = new Forge1122ChunkAdapter(overworld);
adapter.registerEventHooks(pendingDeltas::add);
adapter = new Forge1122ChunkAdapter(overworld);
adapter.registerEventHooks(new ChunkAdapter.DeltaSink() {
@Override
public void onDelta(DeltaEvent delta) {
pendingDeltas.add(delta);
}
@Override
public void onChunkDirty(int chunkX, int chunkZ) {
pendingSectionChunks.add((((long) chunkX) << 32) | (chunkZ & 0xFFFFFFFFL));
}
});
MinecraftForge.EVENT_BUS.register(this);
}
@@ -85,11 +102,30 @@ public class MCMapperMod {
private void flush() {
List<DeltaEvent> batch;
synchronized (pendingDeltas) {
if (pendingDeltas.isEmpty()) return;
batch = new ArrayList<>(pendingDeltas);
pendingDeltas.clear();
if (pendingDeltas.isEmpty()) {
batch = null;
} else {
batch = new ArrayList<>(pendingDeltas);
pendingDeltas.clear();
}
}
if (batch != null) connection.sendDeltas(batch);
List<Long> dirtyChunks;
synchronized (pendingSectionChunks) {
if (pendingSectionChunks.isEmpty()) return;
dirtyChunks = new ArrayList<>(pendingSectionChunks);
pendingSectionChunks.clear();
}
String dimensionId = adapter.getDimensionId();
for (long key : dirtyChunks) {
int chunkX = (int) (key >> 32);
int chunkZ = (int) key;
List<SectionData> sections = adapter.readSections(chunkX, chunkZ);
if (!sections.isEmpty()) {
connection.sendSections(dimensionId, chunkX, chunkZ, sections);
}
}
connection.sendDeltas(batch);
}
@Mod.EventHandler