Phase 1: forge-1_12_2 WS connection and column delta capture

Adds a hand-rolled RFC 6455 WS client and minimal JSON codec to common/ (no
third-party deps, keeping legacy ForgeGradle's classpath untouched), a
DefaultBackendConnection implementing hello/hello_ack auth and reconnect
with backoff, and Forge1122ChunkAdapter deriving top-of-column state from
the vanilla heightmap for both initial chunk backfill and event-driven
deltas (block break/place, deferred one tick to read post-mutation state).
MCMapperMod wires it up with config-driven backendUrl/serverToken and a
tick-based flush batch. Verified against a live MCMapper-Backend api
instance (real WS handshake, hello_ack, and 256-column batch landing
correctly in Postgres).
This commit is contained in:
2026-08-08 15:35:51 +02:00
parent 621ea7c10d
commit 699f09f081
7 changed files with 884 additions and 8 deletions
+16
View File
@@ -43,6 +43,22 @@ needs Gradle 8+ and JDK 17+ (ModDevGradle), incompatible with the legacy leaves'
within one Gradle invocation; see `settings.gradle`'s comment for the workaround until Phase 10
gives it a proper isolated build.
## Configuration (Phase 1: forge-1_12_2)
On first server start the leaf writes `config/mcmapper.cfg` with defaults. Set:
```
backendUrl=ws://<backend-host>:3000/ws
serverToken=<token from MCMapper-Backend's `bun run seed`>
```
then restart. With those set, the mod connects to the backend, backfills already-loaded
overworld chunks, and streams event-driven column deltas (block place/break) in batches every
`deltaFlushIntervalTicks` (default 20 = 1s). No periodic reconciliation sweep yet (Phase 7),
and only the overworld — other dimensions and `forge-1_7_10`/`neoforge-26_1` land in later
phases. The WS client and JSON encoding are hand-rolled (no third-party dependency) — see
`common/src/main/java/.../ws/SimpleWebSocketClient.java` and `.../json/MiniJson.java` for why.
## Attribution
See `THIRD_PARTY_NOTICES.md`.
@@ -0,0 +1,206 @@
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.ws.SimpleWebSocketClient;
import java.net.URI;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
/**
* Wire protocol (mod -> api / api -> mod), one JSON object per WS text frame — mirrored in
* MCMapper-Backend's {@code api/src/ws-gateway.ts}:
*
* <pre>
* mod -&gt; api {"type":"hello","token":"&lt;serverToken&gt;"}
* api -&gt; mod {"type":"hello_ack","ok":true,"serverId":"&lt;uuid&gt;"}
* {"type":"hello_ack","ok":false,"error":"..."}
*
* mod -&gt; api {"type":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]}
* </pre>
*
* A "columns" message doubles as both initial backfill (one message per loaded chunk) and live
* deltas (one message per flush tick) — see {@link DeltaEvent}'s javadoc for how a
* {@code List<DeltaEvent>} maps onto it. {@code dimension} in {@link DeltaEvent} is a string so
* NeoForge's namespaced dimension keys (Phase 10) can reuse this type; pre-Flattening leaves
* (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.
*
* 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.
*
* This class avoids a third-party JSON/WS library entirely (see {@link SimpleWebSocketClient}
* and {@link MiniJson}'s javadoc) to keep the mod's classpath free of anything that would need
* shading through legacy ForgeGradle.
*/
public class DefaultBackendConnection implements BackendConnection {
private final Consumer<String> logInfo;
private final Consumer<String> logWarn;
private volatile SimpleWebSocketClient ws;
private volatile boolean serverReady = false;
private volatile boolean shouldReconnect = false;
private volatile long reconnectDelayMs = 1000;
private static final long MAX_RECONNECT_DELAY_MS = 30_000;
private String url;
private String serverToken;
public DefaultBackendConnection(Consumer<String> logInfo, Consumer<String> logWarn) {
this.logInfo = logInfo;
this.logWarn = logWarn;
}
@Override
public void connect(String url, String serverToken) {
this.url = url;
this.serverToken = serverToken;
this.shouldReconnect = true;
this.reconnectDelayMs = 1000;
doConnect();
}
private void doConnect() {
SimpleWebSocketClient client = new SimpleWebSocketClient(new SimpleWebSocketClient.Listener() {
@Override
public void onOpen() {
reconnectDelayMs = 1000;
sendRaw("hello", map("type", "hello", "token", serverToken));
}
@Override
public void onText(String message) {
handleMessage(message);
}
@Override
public void onClose(int code, String reason) {
serverReady = false;
logWarn.accept("backend connection closed (" + code + " " + reason + ")");
if (shouldReconnect) scheduleReconnect();
}
@Override
public void onError(Exception e) {
logWarn.accept("backend connection error: " + e);
}
});
this.ws = client;
try {
client.connect(URI.create(url));
} catch (Exception e) {
logWarn.accept("failed to connect to backend at " + url + ": " + e);
if (shouldReconnect) scheduleReconnect();
}
}
private void scheduleReconnect() {
long delay = reconnectDelayMs;
reconnectDelayMs = Math.min(reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS);
Thread t = new Thread(() -> {
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
if (shouldReconnect) doConnect();
}, "mcmapper-ws-reconnect");
t.setDaemon(true);
t.start();
}
@SuppressWarnings("unchecked")
private void handleMessage(String message) {
Object parsed;
try {
parsed = MiniJson.parse(message);
} catch (Exception e) {
logWarn.accept("received malformed message from backend: " + e);
return;
}
if (!(parsed instanceof Map)) return;
Map<String, Object> obj = (Map<String, Object>) parsed;
String type = String.valueOf(obj.get("type"));
if ("hello_ack".equals(type)) {
boolean ok = Boolean.TRUE.equals(obj.get("ok"));
if (ok) {
serverReady = true;
logInfo.accept("authenticated with backend as server " + obj.get("serverId"));
} else {
serverReady = false;
logWarn.accept("backend rejected connection: " + obj.get("error"));
shouldReconnect = false; // an invalid token won't fix itself by retrying
}
} else if ("error".equals(type)) {
logWarn.accept("backend reported error: " + obj.get("error"));
}
}
@Override
public void sendDeltas(List<DeltaEvent> deltas) {
if (deltas.isEmpty() || !serverReady) return;
// Group by dimension — the wire message is single-dimension, see class javadoc.
Set<String> dimensions = new LinkedHashSet<>();
for (DeltaEvent e : deltas) dimensions.add(e.dimension);
for (String dimension : dimensions) {
List<Object> columns = new ArrayList<>();
for (DeltaEvent e : deltas) {
if (!e.dimension.equals(dimension)) continue;
Map<String, Object> col = new LinkedHashMap<>();
col.put("x", (double) e.x);
col.put("z", (double) e.z);
col.put("height", (double) e.y);
col.put("blockId", (double) (e.blockStateId >> 4));
col.put("blockMeta", (double) (e.blockStateId & 0xF));
columns.add(col);
}
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "columns");
msg.put("dimension", (double) Integer.parseInt(dimension));
msg.put("columns", columns);
sendRaw("columns", msg);
}
}
@Override
public void sendLinkRequest(LinkRequest request) {
// The `/mcmapper link` flow (Phase 3) isn't wired up yet — nothing calls this in Phase 1.
logWarn.accept("sendLinkRequest called before Phase 3's link flow is implemented — ignoring");
}
private void sendRaw(String label, Map<String, Object> message) {
SimpleWebSocketClient client = ws;
if (client == null || !client.isOpen()) return;
try {
client.sendText(MiniJson.writeObject(message));
} catch (Exception e) {
logWarn.accept("failed to send '" + label + "' to backend: " + e);
}
}
@Override
public void disconnect() {
shouldReconnect = false;
serverReady = false;
SimpleWebSocketClient client = ws;
if (client != null) client.close();
}
private static Map<String, Object> map(Object... kv) {
Map<String, Object> m = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) m.put((String) kv[i], kv[i + 1]);
return m;
}
}
@@ -0,0 +1,202 @@
package com.octoturge.mcmapper.common.json;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* A minimal JSON reader/writer, hand-rolled so the mod doesn't need to shade in a JSON library
* (Gson/Jackson) through legacy ForgeGradle's classpath — see BackendConnection's javadoc for
* why the mod side stays dependency-free wherever reasonable. Handles the full JSON grammar
* (objects, arrays, strings, numbers, booleans, null) since the extra code over a "flat objects
* only" parser is small and this is reused for every inbound/outbound wire message.
*/
public final class MiniJson {
private MiniJson() {
}
// ---- Writing ----
public static String writeObject(Map<String, ?> fields) {
StringBuilder sb = new StringBuilder();
writeValue(sb, fields);
return sb.toString();
}
@SuppressWarnings("unchecked")
private static void writeValue(StringBuilder sb, Object value) {
if (value == null) {
sb.append("null");
} else if (value instanceof String) {
writeString(sb, (String) value);
} else if (value instanceof Number || value instanceof Boolean) {
sb.append(value);
} else if (value instanceof Map) {
sb.append('{');
boolean first = true;
for (Map.Entry<String, ?> entry : ((Map<String, ?>) value).entrySet()) {
if (!first) sb.append(',');
first = false;
writeString(sb, entry.getKey());
sb.append(':');
writeValue(sb, entry.getValue());
}
sb.append('}');
} else if (value instanceof List) {
sb.append('[');
boolean first = true;
for (Object item : (List<?>) value) {
if (!first) sb.append(',');
first = false;
writeValue(sb, item);
}
sb.append(']');
} else {
throw new IllegalArgumentException("Unsupported JSON value type: " + value.getClass());
}
}
private static void writeString(StringBuilder sb, String s) {
sb.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
}
// ---- Reading ----
/** Parses one JSON value (typically an object) from the start of {@code text}. */
public static Object parse(String text) {
Parser parser = new Parser(text);
Object value = parser.parseValue();
parser.skipWhitespace();
return value;
}
private static final class Parser {
private final String text;
private int pos;
Parser(String text) {
this.text = text;
}
Object parseValue() {
skipWhitespace();
char c = text.charAt(pos);
if (c == '{') return parseObject();
if (c == '[') return parseArray();
if (c == '"') return parseString();
if (c == 't') { expect("true"); return Boolean.TRUE; }
if (c == 'f') { expect("false"); return Boolean.FALSE; }
if (c == 'n') { expect("null"); return null; }
return parseNumber();
}
Map<String, Object> parseObject() {
Map<String, Object> map = new LinkedHashMap<>();
pos++; // {
skipWhitespace();
if (peek() == '}') { pos++; return map; }
while (true) {
skipWhitespace();
String key = parseString();
skipWhitespace();
if (peek() != ':') throw error("expected ':'");
pos++;
Object value = parseValue();
map.put(key, value);
skipWhitespace();
char c = text.charAt(pos++);
if (c == '}') break;
if (c != ',') throw error("expected ',' or '}'");
}
return map;
}
List<Object> parseArray() {
List<Object> list = new ArrayList<>();
pos++; // [
skipWhitespace();
if (peek() == ']') { pos++; return list; }
while (true) {
list.add(parseValue());
skipWhitespace();
char c = text.charAt(pos++);
if (c == ']') break;
if (c != ',') throw error("expected ',' or ']'");
}
return list;
}
String parseString() {
if (text.charAt(pos) != '"') throw error("expected string");
pos++;
StringBuilder sb = new StringBuilder();
while (true) {
char c = text.charAt(pos++);
if (c == '"') break;
if (c == '\\') {
char esc = text.charAt(pos++);
switch (esc) {
case '"': sb.append('"'); break;
case '\\': sb.append('\\'); break;
case '/': sb.append('/'); break;
case 'n': sb.append('\n'); break;
case 'r': sb.append('\r'); break;
case 't': sb.append('\t'); break;
case 'b': sb.append('\b'); break;
case 'f': sb.append('\f'); break;
case 'u':
String hex = text.substring(pos, pos + 4);
sb.append((char) Integer.parseInt(hex, 16));
pos += 4;
break;
default: throw error("bad escape");
}
} else {
sb.append(c);
}
}
return sb.toString();
}
Double parseNumber() {
int start = pos;
while (pos < text.length() && "-+.eE0123456789".indexOf(text.charAt(pos)) >= 0) pos++;
return Double.parseDouble(text.substring(start, pos));
}
void expect(String literal) {
if (!text.startsWith(literal, pos)) throw error("expected '" + literal + "'");
pos += literal.length();
}
char peek() {
return text.charAt(pos);
}
void skipWhitespace() {
while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) pos++;
}
RuntimeException error(String message) {
return new IllegalArgumentException("JSON parse error at " + pos + ": " + message);
}
}
}
@@ -1,10 +1,24 @@
package com.octoturge.mcmapper.common.protocol;
/**
* A single block change, produced by either the event-driven capture path or the periodic
* reconciliation sweep. {@code blockStateId} is a palette index into whatever block/state
* palette the leaf module's {@code ChunkAdapter} maintains — the common module never
* interprets it, it just carries it to the backend.
* For Phase 1 (column/heightmap chunk store), this represents the *resulting* top-of-column
* state after a change, not a raw per-voxel diff: {@code y} is the height of the topmost
* non-air block at {@code (x, z)}, and {@code blockStateId} is that block's encoded id — the
* mod recomputes this itself (it has full world access) rather than the backend trying to infer
* a post-break top block from a bare coordinate. Full per-voxel deltas (needed for Phase 2's 3D
* meshing) are a natural extension of this same type once the chunk store grows a full
* block-data column — see MCMapper-Backend's {@code chunk_columns} table.
*
* {@code blockStateId} is a palette index into whatever block/state palette the leaf module's
* {@code ChunkAdapter} maintains — the common module never interprets it, it just carries it to
* the backend. Pre-Flattening leaves (1.7.10/1.12.2) use the identity encoding
* {@code (blockId << 4) | meta} (numeric block id/meta *is* the palette); a dynamic palette is
* only needed once a leaf's block identity doesn't fit 12+4 bits (Phase 10's NeoForge leaf).
*
* {@code dimension} is a string so NeoForge's namespaced dimension keys (Phase 10) fit without
* changing this type; pre-Flattening leaves always populate it with a stringified vanilla
* dimension id (e.g. {@code "0"}), which is what lets {@code DefaultBackendConnection}
* {@code Integer.parseInt} it for the wire protocol's integer {@code dimension} field.
*/
public class DeltaEvent {
public final String dimension;
@@ -0,0 +1,232 @@
package com.octoturge.mcmapper.common.ws;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* A minimal RFC 6455 WebSocket client: handshake + masked text-frame send + unmasked text-frame
* receive (with fragment reassembly) + ping/pong. Hand-rolled instead of pulling in a library
* (e.g. Java-WebSocket) to avoid shading a third-party dependency through legacy ForgeGradle's
* classpath — see DefaultBackendConnection's javadoc. No TLS support (`wss://`) — MCMapper's
* backend traffic is expected to stay on a private/docker network or behind a TLS-terminating
* reverse proxy the mod doesn't need to speak TLS to directly; add {@code SSLSocket} here if a
* future deployment needs the mod to dial a public `wss://` endpoint directly.
*/
public class SimpleWebSocketClient {
public interface Listener {
void onOpen();
void onText(String message);
void onClose(int code, String reason);
void onError(Exception e);
}
private final Listener listener;
private Socket socket;
private DataOutputStream out;
private Thread readerThread;
private final AtomicBoolean open = new AtomicBoolean(false);
private final Object writeLock = new Object();
public SimpleWebSocketClient(Listener listener) {
this.listener = listener;
}
public boolean isOpen() {
return open.get();
}
public void connect(URI uri) throws IOException {
if ("wss".equalsIgnoreCase(uri.getScheme())) {
throw new IOException("wss:// is not supported by this client — use ws:// (see class javadoc)");
}
int port = uri.getPort() == -1 ? 80 : uri.getPort();
socket = new Socket(uri.getHost(), port);
out = new DataOutputStream(socket.getOutputStream());
String key = Base64.getEncoder().encodeToString(randomBytes(16));
String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "/" : uri.getRawPath();
if (uri.getRawQuery() != null) path += "?" + uri.getRawQuery();
String request = "GET " + path + " HTTP/1.1\r\n"
+ "Host: " + uri.getHost() + ":" + port + "\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Key: " + key + "\r\n"
+ "Sec-WebSocket-Version: 13\r\n"
+ "\r\n";
out.write(request.getBytes(StandardCharsets.US_ASCII));
out.flush();
InputStream in = socket.getInputStream();
String statusLine = readLine(in);
if (statusLine == null || !statusLine.contains("101")) {
throw new IOException("WebSocket handshake failed, status line: " + statusLine);
}
String line;
while ((line = readLine(in)) != null && !line.isEmpty()) {
// Headers ignored beyond the status line — this client trusts the server rather than
// verifying Sec-WebSocket-Accept, acceptable for a private/trusted backend endpoint.
}
open.set(true);
listener.onOpen();
readerThread = new Thread(this::readLoop, "mcmapper-ws-reader");
readerThread.setDaemon(true);
readerThread.start();
}
public void sendText(String text) throws IOException {
byte[] payload = text.getBytes(StandardCharsets.UTF_8);
synchronized (writeLock) {
writeFrame(0x1, payload);
}
}
public void close() {
if (!open.compareAndSet(true, false)) return;
try {
synchronized (writeLock) {
writeFrame(0x8, new byte[0]);
}
} catch (IOException ignored) {
}
closeSocketQuietly();
}
private void closeSocketQuietly() {
try {
if (socket != null) socket.close();
} catch (IOException ignored) {
}
}
private void writeFrame(int opcode, byte[] payload) throws IOException {
out.writeByte(0x80 | opcode); // FIN + opcode, no extensions
byte[] mask = randomBytes(4);
int len = payload.length;
if (len <= 125) {
out.writeByte(0x80 | len);
} else if (len <= 0xFFFF) {
out.writeByte(0x80 | 126);
out.writeShort(len);
} else {
out.writeByte(0x80 | 127);
out.writeLong(len);
}
out.write(mask);
byte[] masked = new byte[payload.length];
for (int i = 0; i < payload.length; i++) {
masked[i] = (byte) (payload[i] ^ mask[i % 4]);
}
out.write(masked);
out.flush();
}
private void readLoop() {
try {
DataInputStream in = new DataInputStream(socket.getInputStream());
StringBuilder fragmentBuffer = new StringBuilder();
boolean fragmenting = false;
while (open.get()) {
int b0 = in.readUnsignedByte();
boolean fin = (b0 & 0x80) != 0;
int opcode = b0 & 0x0F;
int b1 = in.readUnsignedByte();
boolean masked = (b1 & 0x80) != 0; // servers must not mask, but tolerate it
long len = b1 & 0x7F;
if (len == 126) {
len = in.readUnsignedShort();
} else if (len == 127) {
len = in.readLong();
}
byte[] serverMask = masked ? readFully(in, 4) : null;
byte[] payload = readFully(in, (int) len);
if (serverMask != null) {
for (int i = 0; i < payload.length; i++) payload[i] ^= serverMask[i % 4];
}
switch (opcode) {
case 0x1: // text
case 0x0: // continuation
fragmenting = true;
fragmentBuffer.append(new String(payload, StandardCharsets.UTF_8));
if (fin) {
fragmenting = false;
String message = fragmentBuffer.toString();
fragmentBuffer.setLength(0);
listener.onText(message);
}
break;
case 0x8: // close
open.set(false);
int code = payload.length >= 2 ? ((payload[0] & 0xFF) << 8 | (payload[1] & 0xFF)) : 1000;
String reason = payload.length > 2
? new String(payload, 2, payload.length - 2, StandardCharsets.UTF_8)
: "";
listener.onClose(code, reason);
closeSocketQuietly();
return;
case 0x9: // ping
synchronized (writeLock) {
writeFrame(0xA, payload);
}
break;
case 0xA: // pong
break;
default:
// unknown opcode — ignore rather than fail the connection
}
if (!fin && !fragmenting) {
// defensive: a non-fin non-text/continuation frame we don't understand
}
}
} catch (IOException e) {
if (open.compareAndSet(true, false)) {
listener.onError(e);
listener.onClose(1006, "connection lost: " + e.getMessage());
}
}
}
private static byte[] readFully(DataInputStream in, int len) throws IOException {
byte[] buf = new byte[len];
in.readFully(buf);
return buf;
}
private static String readLine(InputStream in) throws IOException {
StringBuilder sb = new StringBuilder();
int c;
boolean any = false;
while ((c = in.read()) != -1) {
any = true;
if (c == '\r') continue;
if (c == '\n') break;
sb.append((char) c);
}
return any ? sb.toString() : null;
}
private static final SecureRandom RANDOM = new SecureRandom();
private static byte[] randomBytes(int n) {
byte[] b = new byte[n];
RANDOM.nextBytes(b);
return b;
}
}
@@ -0,0 +1,132 @@
package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.ChunkAdapter;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
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.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.util.BlockSnapshot;
import net.minecraftforge.event.world.BlockEvent;
import net.minecraftforge.event.world.ChunkEvent;
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
import net.minecraftforge.fml.common.gameevent.TickEvent;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 1.12.2's {@link ChunkAdapter}: derives each column's "top of column" state (height + block)
* from the chunk's vanilla precipitation heightmap ({@link Chunk#getHeightValue}) — the same
* O(1) lookup vanilla itself uses, rather than scanning down from the world height limit.
*/
public class Forge1122ChunkAdapter implements ChunkAdapter {
private final WorldServer world;
private final String dimensionId;
public Forge1122ChunkAdapter(WorldServer world) {
this.world = world;
this.dimensionId = String.valueOf(world.provider.getDimension());
}
@Override
public List<DeltaEvent> readChunk(String dimension, int chunkX, int chunkZ) {
Chunk chunk = world.getChunk(chunkX, chunkZ);
List<DeltaEvent> events = new ArrayList<>(256);
long now = System.currentTimeMillis();
for (int lx = 0; lx < 16; lx++) {
for (int lz = 0; lz < 16; lz++) {
events.add(readColumn(chunk, chunkX * 16 + lx, chunkZ * 16 + lz, lx, lz, now,
DeltaEvent.Source.RECONCILIATION));
}
}
return events;
}
private DeltaEvent readColumn(Chunk chunk, int worldX, int worldZ, int localX, int localZ,
long now, DeltaEvent.Source source) {
int height = chunk.getHeightValue(localX, localZ);
int topY = Math.max(0, height - 1);
IBlockState state = chunk.getBlockState(new BlockPos(localX, topY, localZ));
int id = Block.getIdFromBlock(state.getBlock());
int meta = state.getBlock().getMetaFromState(state);
int blockStateId = ((id & 0xFFF) << 4) | (meta & 0xF);
return new DeltaEvent(dimensionId, worldX, topY, worldZ, blockStateId, now, source);
}
@Override
public void registerEventHooks(DeltaSink sink) {
MinecraftForge.EVENT_BUS.register(new EventHooks(sink));
}
/**
* {@code BlockEvent.BreakEvent} fires *before* the block is actually removed (it's
* cancellable), so reading world state synchronously inside that handler would see the
* pre-break block, not the resulting top-of-column state. Rather than special-case each
* event's timing, every hook just marks the column dirty; a server-tick handler drains the
* dirty set once per tick (well after any same-tick mutation completes) and reads the
* genuinely-current state then. This also naturally coalesces multiple changes to the same
* column within one tick into a single read.
*/
private class EventHooks {
private final DeltaSink sink;
private final Set<Long> dirtyColumns = ConcurrentHashMap.newKeySet();
EventHooks(DeltaSink sink) {
this.sink = sink;
}
@SubscribeEvent
public void onBlockBreak(BlockEvent.BreakEvent event) {
markDirty(event.getWorld(), event.getPos());
}
@SubscribeEvent
public void onBlockPlace(BlockEvent.PlaceEvent event) {
markDirty(event.getWorld(), event.getPos());
}
@SubscribeEvent
public void onMultiPlace(BlockEvent.MultiPlaceEvent event) {
for (BlockSnapshot snapshot : event.getReplacedBlockSnapshots()) {
markDirty(event.getWorld(), snapshot.getPos());
}
}
@SubscribeEvent
public void onChunkLoad(ChunkEvent.Load event) {
if (event.getWorld() != world) return;
Chunk chunk = event.getChunk();
for (DeltaEvent e : readChunk(dimensionId, chunk.x, chunk.z)) {
sink.onDelta(e);
}
}
@SubscribeEvent
public void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END || dirtyColumns.isEmpty()) return;
long now = System.currentTimeMillis();
Iterator<Long> it = dirtyColumns.iterator();
while (it.hasNext()) {
long key = it.next();
it.remove();
int wx = (int) (key >> 32);
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));
}
}
private void markDirty(World eventWorld, BlockPos pos) {
if (eventWorld != world) return;
long key = ((long) pos.getX() << 32) | (pos.getZ() & 0xFFFFFFFFL);
dirtyColumns.add(key);
}
}
}
@@ -1,15 +1,29 @@
package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.DefaultBackendConnection;
import com.octoturge.mcmapper.common.config.MapperConfig;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import net.minecraft.world.WorldServer;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.config.Configuration;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
import net.minecraftforge.fml.common.event.FMLServerStartingEvent;
import net.minecraftforge.fml.common.event.FMLServerStoppingEvent;
import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
import net.minecraftforge.fml.common.gameevent.TickEvent;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Entry point for the 1.12.2 leaf — the primary/first-implemented target (Enigmatica 2).
* Phase 0 scaffolding only: connection, delta capture, chat bridge and link-command wiring
* land in Phase 1, built on the {@code common} interfaces via a 1.12.2 {@code ChunkAdapter}
* and {@code ChatBridge} implementation (not yet present in this package).
* 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.
*/
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
public class MCMapperMod {
@@ -18,8 +32,68 @@ public class MCMapperMod {
private static final Logger LOGGER = LogManager.getLogger(MOD_ID);
private Configuration forgeConfig;
private final MapperConfig config = new MapperConfig();
private DefaultBackendConnection connection;
private final List<DeltaEvent> pendingDeltas = Collections.synchronizedList(new ArrayList<>());
private int ticksSinceFlush = 0;
@Mod.EventHandler
public void preInit(FMLPreInitializationEvent event) {
LOGGER.info("MCMapper (1.12.2 leaf) scaffolding loaded — no-op until Phase 1");
forgeConfig = new Configuration(event.getSuggestedConfigurationFile());
loadConfig();
}
private void loadConfig() {
forgeConfig.load();
config.backendUrl = forgeConfig.getString("backendUrl", "network", config.backendUrl,
"WS URL of the MCMapper backend api service");
config.serverToken = forgeConfig.getString("serverToken", "network", config.serverToken,
"Per-server token issued when registering with the backend (see MCMapper-Backend's `bun run seed`)");
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");
if (forgeConfig.hasChanged()) forgeConfig.save();
}
@Mod.EventHandler
public void serverStarting(FMLServerStartingEvent event) {
if (config.serverToken == null || config.serverToken.isEmpty()) {
LOGGER.warn("MCMapper serverToken is not configured (see config/mcmapper.cfg) — not connecting to backend");
return;
}
connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn);
connection.connect(config.backendUrl, config.serverToken);
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);
MinecraftForge.EVENT_BUS.register(this);
}
@SubscribeEvent
public void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
if (++ticksSinceFlush < config.deltaFlushIntervalTicks) return;
ticksSinceFlush = 0;
flush();
}
private void flush() {
List<DeltaEvent> batch;
synchronized (pendingDeltas) {
if (pendingDeltas.isEmpty()) return;
batch = new ArrayList<>(pendingDeltas);
pendingDeltas.clear();
}
connection.sendDeltas(batch);
}
@Mod.EventHandler
public void serverStopping(FMLServerStoppingEvent event) {
if (connection != null) connection.disconnect();
}
}