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,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<DeltaEvent> deltas);
void sendSections(String dimension, int chunkX, int chunkZ, List<SectionData> sections);
void sendLinkRequest(LinkRequest request);
void disconnect();
@@ -31,6 +34,10 @@ public interface BackendConnection {
public void sendDeltas(List<DeltaEvent> deltas) {
}
@Override
public void sendSections(String dimension, int chunkX, int chunkZ, List<SectionData> sections) {
}
@Override
public void sendLinkRequest(LinkRequest request) {
}
@@ -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<DeltaEvent> readChunk(String dimension, int chunkX, int chunkZ);
/** Bulk-read a chunk's non-empty 16x16x16 sections (Phase 2, 3D meshing backfill). */
List<SectionData> 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) {
}
}
}
@@ -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 -&gt; api {"type":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]}
* mod -&gt; api {"type":"sections","dimension":0,"chunkX":..,"chunkZ":..,"sections":[{"sectionY":..,"blocks":"&lt;base64&gt;"}]}
* </pre>
*
* 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<SectionData> sections) {
if (sections.isEmpty() || !serverReady) return;
List<Object> sectionList = new ArrayList<>();
for (SectionData s : sections) {
Map<String, Object> sectionObj = new LinkedHashMap<>();
sectionObj.put("sectionY", (double) s.sectionY);
sectionObj.put("blocks", encodeBlocksBase64(s.blocks));
sectionList.add(sectionObj);
}
Map<String, Object> 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.
@@ -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;
}
}