From 2b7e0e090dbc23e85bba1f86689edacb4d1f7c46 Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sat, 8 Aug 2026 16:19:32 +0200 Subject: [PATCH] Phase 2: forge-1_12_2 full chunk section backfill/flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../mcmapper/common/BackendConnection.java | 11 +++- .../mcmapper/common/ChunkAdapter.java | 19 ++++++- .../common/DefaultBackendConnection.java | 38 ++++++++++++++ .../mcmapper/common/protocol/SectionData.java | 25 ++++++++++ .../forge1122/Forge1122ChunkAdapter.java | 42 ++++++++++++++++ .../mcmapper/forge1122/MCMapperMod.java | 50 ++++++++++++++++--- 6 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 common/src/main/java/com/octoturge/mcmapper/common/protocol/SectionData.java diff --git a/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java b/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java index e297f9b..337ce4c 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java @@ -2,6 +2,7 @@ package com.octoturge.mcmapper.common; import com.octoturge.mcmapper.common.protocol.DeltaEvent; import com.octoturge.mcmapper.common.protocol.LinkRequest; +import com.octoturge.mcmapper.common.protocol.SectionData; import java.util.List; @@ -10,14 +11,16 @@ import java.util.List; * modules (pure Java, no Minecraft API usage) — a docker-network hostname, LAN IP, or public * domain in {@code MapperConfig#backendUrl} all work identically. * - * Left as an interface with a no-op stub for Phase 0 scaffolding; the real WS client - * (handshake, reconnect/backoff, batching) lands in Phase 1. + * Real implementation ({@link DefaultBackendConnection}) landed in Phase 1 (column deltas) and + * grew {@link #sendSections} in Phase 2 (full-voxel 3D mesh backfill). */ public interface BackendConnection { void connect(String url, String serverToken); void sendDeltas(List deltas); + void sendSections(String dimension, int chunkX, int chunkZ, List sections); + void sendLinkRequest(LinkRequest request); void disconnect(); @@ -31,6 +34,10 @@ public interface BackendConnection { public void sendDeltas(List deltas) { } + @Override + public void sendSections(String dimension, int chunkX, int chunkZ, List sections) { + } + @Override public void sendLinkRequest(LinkRequest request) { } diff --git a/common/src/main/java/com/octoturge/mcmapper/common/ChunkAdapter.java b/common/src/main/java/com/octoturge/mcmapper/common/ChunkAdapter.java index 0328670..54bc462 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/ChunkAdapter.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/ChunkAdapter.java @@ -1,6 +1,7 @@ package com.octoturge.mcmapper.common; import com.octoturge.mcmapper.common.protocol.DeltaEvent; +import com.octoturge.mcmapper.common.protocol.SectionData; import java.util.List; @@ -8,16 +9,30 @@ import java.util.List; * The seam between a specific Minecraft/Forge API generation and the shared delta-capture and * networking logic. Each leaf module (forge-1_7_10, forge-1_12_2, neoforge-26_1) provides one * implementation, adapting its own era's block-id/block-state representation into the - * {@code blockStateId} carried by {@link DeltaEvent}. + * {@code blockStateId} carried by {@link DeltaEvent} and {@link SectionData}. */ public interface ChunkAdapter { - /** Bulk-read a chunk's current state for initial sync / reconciliation, as delta events. */ + /** Bulk-read a chunk's current column/heightmap state for initial sync, as delta events. */ List readChunk(String dimension, int chunkX, int chunkZ); + /** Bulk-read a chunk's non-empty 16x16x16 sections (Phase 2, 3D meshing backfill). */ + List readSections(int chunkX, int chunkZ); + /** Register the loader-specific hooks (block place/break, chunk load/unload) that feed the dirty buffer. */ void registerEventHooks(DeltaSink sink); interface DeltaSink { void onDelta(DeltaEvent event); + + /** + * A chunk had a block change (or was freshly loaded) — its full section data should be + * re-read and resent. Separate from {@link #onDelta} because section reads are far more + * expensive than a single column read; the caller decides how/when to batch these + * (typically once per flush, deduped per chunk, same as column deltas). + * Default no-op so existing single-method {@code DeltaSink} lambdas (which only + * implement {@code onDelta}) keep compiling. + */ + default void onChunkDirty(int chunkX, int chunkZ) { + } } } diff --git a/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java b/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java index 1c7a3f3..e17a9c3 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java @@ -3,10 +3,12 @@ package com.octoturge.mcmapper.common; import com.octoturge.mcmapper.common.json.MiniJson; import com.octoturge.mcmapper.common.protocol.DeltaEvent; import com.octoturge.mcmapper.common.protocol.LinkRequest; +import com.octoturge.mcmapper.common.protocol.SectionData; import com.octoturge.mcmapper.common.ws.SimpleWebSocketClient; import java.net.URI; import java.util.ArrayList; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -24,6 +26,7 @@ import java.util.function.Consumer; * {"type":"hello_ack","ok":false,"error":"..."} * * mod -> api {"type":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]} + * mod -> api {"type":"sections","dimension":0,"chunkX":..,"chunkZ":..,"sections":[{"sectionY":..,"blocks":"<base64>"}]} * * * A "columns" message doubles as both initial backfill (one message per loaded chunk) and live @@ -33,6 +36,11 @@ import java.util.function.Consumer; * (1.7.10/1.12.2) always populate it with a stringified vanilla dimension id (e.g. {@code "0"}), * which is what lets this class safely {@code Integer.parseInt} it for the wire message. * + * "sections" is the Phase 2 addition for full-voxel 3D mesh backfill, additive to "columns" — + * see {@link SectionData}'s javadoc for the block encoding and {@code ChunkAdapter.DeltaSink + * #onChunkDirty} for when it's sent (chunk load, and per-flush for chunks touched since the + * last flush). + * * No offline queue: deltas sent while disconnected are dropped rather than buffered — the * periodic reconciliation sweep (not yet built, see plan's Phase 7) is what's meant to catch * whatever a disconnect window missed, so buffering here would be solving the same problem twice. @@ -174,6 +182,36 @@ public class DefaultBackendConnection implements BackendConnection { } } + @Override + public void sendSections(String dimension, int chunkX, int chunkZ, List sections) { + if (sections.isEmpty() || !serverReady) return; + + List sectionList = new ArrayList<>(); + for (SectionData s : sections) { + Map sectionObj = new LinkedHashMap<>(); + sectionObj.put("sectionY", (double) s.sectionY); + sectionObj.put("blocks", encodeBlocksBase64(s.blocks)); + sectionList.add(sectionObj); + } + Map msg = new LinkedHashMap<>(); + msg.put("type", "sections"); + msg.put("dimension", (double) Integer.parseInt(dimension)); + msg.put("chunkX", (double) chunkX); + msg.put("chunkZ", (double) chunkZ); + msg.put("sections", sectionList); + sendRaw("sections", msg); + } + + private static String encodeBlocksBase64(char[] blocks) { + byte[] bytes = new byte[blocks.length * 2]; + for (int i = 0; i < blocks.length; i++) { + char v = blocks[i]; + bytes[i * 2] = (byte) (v & 0xFF); + bytes[i * 2 + 1] = (byte) ((v >> 8) & 0xFF); + } + return Base64.getEncoder().encodeToString(bytes); + } + @Override public void sendLinkRequest(LinkRequest request) { // The `/mcmapper link` flow (Phase 3) isn't wired up yet — nothing calls this in Phase 1. diff --git a/common/src/main/java/com/octoturge/mcmapper/common/protocol/SectionData.java b/common/src/main/java/com/octoturge/mcmapper/common/protocol/SectionData.java new file mode 100644 index 0000000..10cf85c --- /dev/null +++ b/common/src/main/java/com/octoturge/mcmapper/common/protocol/SectionData.java @@ -0,0 +1,25 @@ +package com.octoturge.mcmapper.common.protocol; + +/** + * Full-voxel data for one 16x16x16 chunk section (Phase 2, 3D meshing) — additive to + * {@link DeltaEvent}'s column/heightmap data, not a replacement (see that type's javadoc). + * + * {@code blocks} holds 4096 entries indexed by {@code (ly*16 + lz)*16 + lx}, one per position + * in the section, each the same {@code (blockId << 4) | meta} encoding as {@link + * DeltaEvent#blockStateId}. It's a {@code char[]} (not {@code short[]}) specifically because + * Java's {@code char} is the JVM's only unsigned 16-bit integer type — a {@code short} would + * overflow into negative values for any blockId >= 2048 (id 2048 << 4 already exceeds + * {@code Short.MAX_VALUE}). + */ +public class SectionData { + public final int sectionY; + public final char[] blocks; + + public SectionData(int sectionY, char[] blocks) { + if (blocks.length != 4096) { + throw new IllegalArgumentException("blocks must have exactly 4096 entries, got " + blocks.length); + } + this.sectionY = sectionY; + this.blocks = blocks; + } +} diff --git a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChunkAdapter.java b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChunkAdapter.java index 87018df..0a5c85d 100644 --- a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChunkAdapter.java +++ b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChunkAdapter.java @@ -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 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 readSections(int chunkX, int chunkZ) { + Chunk chunk = world.getChunk(chunkX, chunkZ); + ExtendedBlockStorage[] storage = chunk.getBlockStorageArray(); + List 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 dirtyChunks = new LinkedHashSet<>(); Iterator 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); } } diff --git a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/MCMapperMod.java b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/MCMapperMod.java index fd7b616..6765700 100644 --- a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/MCMapperMod.java +++ b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/MCMapperMod.java @@ -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 pendingDeltas = Collections.synchronizedList(new ArrayList<>()); + private final Set 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 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 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 sections = adapter.readSections(chunkX, chunkZ); + if (!sections.isEmpty()) { + connection.sendSections(dimensionId, chunkX, chunkZ, sections); + } } - connection.sendDeltas(batch); } @Mod.EventHandler