From 699f09f081251d08ebb13601792c3ed3f2aa9b74 Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sat, 8 Aug 2026 15:35:51 +0200 Subject: [PATCH] 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). --- README.md | 16 ++ .../common/DefaultBackendConnection.java | 206 ++++++++++++++++ .../mcmapper/common/json/MiniJson.java | 202 +++++++++++++++ .../mcmapper/common/protocol/DeltaEvent.java | 22 +- .../common/ws/SimpleWebSocketClient.java | 232 ++++++++++++++++++ .../forge1122/Forge1122ChunkAdapter.java | 132 ++++++++++ .../mcmapper/forge1122/MCMapperMod.java | 82 ++++++- 7 files changed, 884 insertions(+), 8 deletions(-) create mode 100644 common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java create mode 100644 common/src/main/java/com/octoturge/mcmapper/common/json/MiniJson.java create mode 100644 common/src/main/java/com/octoturge/mcmapper/common/ws/SimpleWebSocketClient.java create mode 100644 forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChunkAdapter.java diff --git a/README.md b/README.md index ba71ecc..96399e1 100644 --- a/README.md +++ b/README.md @@ -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://:3000/ws +serverToken= +``` + +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`. diff --git a/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java b/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java new file mode 100644 index 0000000..1c7a3f3 --- /dev/null +++ b/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java @@ -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}: + * + *
+ *   mod -> api  {"type":"hello","token":"<serverToken>"}
+ *   api -> mod  {"type":"hello_ack","ok":true,"serverId":"<uuid>"}
+ *               {"type":"hello_ack","ok":false,"error":"..."}
+ *
+ *   mod -> api  {"type":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]}
+ * 
+ * + * 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} 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 logInfo; + private final Consumer 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 logInfo, Consumer 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 obj = (Map) 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 deltas) { + if (deltas.isEmpty() || !serverReady) return; + + // Group by dimension — the wire message is single-dimension, see class javadoc. + Set dimensions = new LinkedHashSet<>(); + for (DeltaEvent e : deltas) dimensions.add(e.dimension); + + for (String dimension : dimensions) { + List columns = new ArrayList<>(); + for (DeltaEvent e : deltas) { + if (!e.dimension.equals(dimension)) continue; + Map 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 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 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 map(Object... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) m.put((String) kv[i], kv[i + 1]); + return m; + } +} diff --git a/common/src/main/java/com/octoturge/mcmapper/common/json/MiniJson.java b/common/src/main/java/com/octoturge/mcmapper/common/json/MiniJson.java new file mode 100644 index 0000000..5f975c8 --- /dev/null +++ b/common/src/main/java/com/octoturge/mcmapper/common/json/MiniJson.java @@ -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 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 entry : ((Map) 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 parseObject() { + Map 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 parseArray() { + List 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); + } + } +} diff --git a/common/src/main/java/com/octoturge/mcmapper/common/protocol/DeltaEvent.java b/common/src/main/java/com/octoturge/mcmapper/common/protocol/DeltaEvent.java index 572e315..5756ed6 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/protocol/DeltaEvent.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/protocol/DeltaEvent.java @@ -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; diff --git a/common/src/main/java/com/octoturge/mcmapper/common/ws/SimpleWebSocketClient.java b/common/src/main/java/com/octoturge/mcmapper/common/ws/SimpleWebSocketClient.java new file mode 100644 index 0000000..18c35bb --- /dev/null +++ b/common/src/main/java/com/octoturge/mcmapper/common/ws/SimpleWebSocketClient.java @@ -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; + } +} 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 new file mode 100644 index 0000000..87018df --- /dev/null +++ b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChunkAdapter.java @@ -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 readChunk(String dimension, int chunkX, int chunkZ) { + Chunk chunk = world.getChunk(chunkX, chunkZ); + List 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 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 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); + } + } +} 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 6a476f5..fd7b616 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,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 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 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(); } }