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
@@ -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();
}
}