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:
@@ -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 -> 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":..}]}
|
||||
* </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;
|
||||
}
|
||||
}
|
||||
+132
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user