Phase 11: extract and ship block textures/registry (forge-1_12_2 leaf)

Forge doesn't split client/server jars, so a modded server's own classpath
already has every loaded mod's block textures, unused server-side.
BlockAssetExtractor reads them via the classloader (best-effort convention
match on registry name -> texture filename) plus the numeric-id -> registry-
name mapping, and ships both to the backend once per connection via two new
wire messages (block_registry, block_textures), fired through a new
BackendConnection#setReadyListener callback so the one-time send can't race
the async WS handshake. Only forge-1_12_2 wires it up so far; the other two
leaves can adopt it later with no protocol change.
This commit is contained in:
2026-08-09 23:20:53 +02:00
parent 52417a7b92
commit 47490309fe
7 changed files with 260 additions and 0 deletions
@@ -1,5 +1,7 @@
package com.octoturge.mcmapper.common;
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
import com.octoturge.mcmapper.common.protocol.BlockTexture;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
@@ -37,6 +39,30 @@ public interface BackendConnection {
*/
void sendPlayerPositions(int dimension, List<PlayerPosition> players);
/**
* Sends this leaf's numeric-blockId -&gt; registry-name dump (Phase 11) — see {@link
* BlockRegistryEntry}'s javadoc. Leaves that don't implement Phase 11 texture extraction
* (see {@link #sendBlockTextures}) can simply never call this; an empty/never-called dump is
* harmless, not an error, on the backend side.
*/
void sendBlockRegistry(List<BlockRegistryEntry> entries);
/**
* Sends this leaf's best-effort classloader-extracted block textures (Phase 11) — see {@link
* BlockTexture}'s javadoc. Implementations should batch calls (many small messages rather
* than one huge frame) rather than requiring the caller to pre-batch.
*/
void sendBlockTextures(List<BlockTexture> textures);
/**
* Registers a callback fired every time the connection successfully authenticates (including
* after an automatic reconnect) — {@code connect()} itself is async (the real handshake
* completes on a later WS frame), so callers with a one-time "just after connecting" send
* (see Phase 11's registry/texture dump) need this rather than calling right after {@code
* connect()}, which would silently no-op (every {@code send*} method is a no-op until ready).
*/
void setReadyListener(Runnable onReady);
/** Registers the callback for web-originated chat messages the backend relays back to us. */
void setChatListener(ChatListener listener);
@@ -78,6 +104,18 @@ public interface BackendConnection {
public void sendPlayerPositions(int dimension, List<PlayerPosition> players) {
}
@Override
public void sendBlockRegistry(List<BlockRegistryEntry> entries) {
}
@Override
public void sendBlockTextures(List<BlockTexture> textures) {
}
@Override
public void setReadyListener(Runnable onReady) {
}
@Override
public void setChatListener(ChatListener listener) {
}
@@ -1,6 +1,8 @@
package com.octoturge.mcmapper.common;
import com.octoturge.mcmapper.common.json.MiniJson;
import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry;
import com.octoturge.mcmapper.common.protocol.BlockTexture;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import com.octoturge.mcmapper.common.protocol.PlayerPosition;
@@ -35,6 +37,9 @@ import java.util.function.Consumer;
* api -&gt; mod {"type":"waypoint_share","name":"...","x":..,"y":..,"z":..,"dimension":..,"color":"#RRGGBB","format":"journeymap"|"xaero"}
*
* mod -&gt; api {"type":"player_positions","dimension":0,"players":[{"uuid":"...","username":"...","x":..,"y":..,"z":..}]}
*
* mod -&gt; api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
* mod -&gt; api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
* </pre>
*
* Phase 3: {@code link_request} is sent by {@code /mcmapper link}; {@code chat} both directions
@@ -72,6 +77,12 @@ import java.util.function.Consumer;
* mod-local {@code playerTrackingEnabled} config, which decides whether the mod computes/sends
* this at all.
*
* Phase 11: {@code block_registry}/{@code block_textures} are sent once, shortly after connecting
* (a world's numeric-id assignments and mod-jar contents don't change without a server restart,
* which restarts the mod too) — see {@link BlockRegistryEntry}/{@link BlockTexture}'s javadocs.
* {@link #sendBlockTextures} batches into multiple messages (see {@code BLOCK_TEXTURE_BATCH_SIZE})
* rather than one huge frame, since a heavily-modded server can have thousands of block textures.
*
* 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.
@@ -90,6 +101,7 @@ public class DefaultBackendConnection implements BackendConnection {
private String serverToken;
private volatile ChatListener chatListener;
private volatile WaypointShareListener waypointShareListener;
private volatile Runnable readyListener;
public DefaultBackendConnection(Consumer<String> logInfo, Consumer<String> logWarn) {
this.logInfo = logInfo;
@@ -173,6 +185,8 @@ public class DefaultBackendConnection implements BackendConnection {
if (ok) {
serverReady = true;
logInfo.accept("authenticated with backend as server " + obj.get("serverId"));
Runnable listener = readyListener;
if (listener != null) listener.run();
} else {
serverReady = false;
logWarn.accept("backend rejected connection: " + obj.get("error"));
@@ -301,6 +315,48 @@ public class DefaultBackendConnection implements BackendConnection {
sendRaw("player_positions", msg);
}
private static final int BLOCK_TEXTURE_BATCH_SIZE = 50;
@Override
public void sendBlockRegistry(List<BlockRegistryEntry> entries) {
if (entries.isEmpty() || !serverReady) return;
List<Object> entryList = new ArrayList<>();
for (BlockRegistryEntry e : entries) {
Map<String, Object> obj = new LinkedHashMap<>();
obj.put("id", (double) e.id);
obj.put("name", e.name);
entryList.add(obj);
}
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "block_registry");
msg.put("entries", entryList);
sendRaw("block_registry", msg);
}
@Override
public void sendBlockTextures(List<BlockTexture> textures) {
if (textures.isEmpty() || !serverReady) return;
for (int start = 0; start < textures.size(); start += BLOCK_TEXTURE_BATCH_SIZE) {
int end = Math.min(start + BLOCK_TEXTURE_BATCH_SIZE, textures.size());
List<Object> textureList = new ArrayList<>();
for (BlockTexture t : textures.subList(start, end)) {
Map<String, Object> obj = new LinkedHashMap<>();
obj.put("name", t.name);
obj.put("dataBase64", Base64.getEncoder().encodeToString(t.pngBytes));
textureList.add(obj);
}
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "block_textures");
msg.put("textures", textureList);
sendRaw("block_textures", msg);
}
}
@Override
public void setReadyListener(Runnable onReady) {
this.readyListener = onReady;
}
@Override
public void setChatListener(ChatListener listener) {
this.chatListener = listener;
@@ -0,0 +1,19 @@
package com.octoturge.mcmapper.common.protocol;
/**
* One entry of a leaf's numeric-blockId -&gt; registry-name dump (Phase 11) — e.g.
* {@code (4000, "botania:manapool")}. Sent once per connection via {@code
* BackendConnection#sendBlockRegistry}, since a world's id assignments (and its mod list) are
* stable for the server's lifetime. Lets the backend eventually resolve the numeric
* {@code blockId}/{@code blockMeta} already carried by {@link DeltaEvent}/{@link SectionData}
* into a texture, for blocks {@link BlockTexture} shipped a texture for.
*/
public final class BlockRegistryEntry {
public final int id;
public final String name;
public BlockRegistryEntry(int id, String name) {
this.id = id;
this.name = name;
}
}
@@ -0,0 +1,20 @@
package com.octoturge.mcmapper.common.protocol;
/**
* One block's texture, extracted by a leaf off its own classloader (Phase 11) — Forge doesn't
* split client/server jars, so every loaded mod's {@code assets/<modid>/textures/...} is already
* sitting on a dedicated server's own classpath, just unused server-side. {@code name} is the
* block's registry name (e.g. {@code "botania:manapool"}, matching {@link
* BlockRegistryEntry#name}) — a best-effort convention match against the block's own path
* segment, not a real blockstate/model JSON resolution (that's Phase 12's job), so not every
* block gets one: a leaf skips extraction rather than guessing when no file matches.
*/
public final class BlockTexture {
public final String name;
public final byte[] pngBytes;
public BlockTexture(String name, byte[] pngBytes) {
this.name = name;
this.pngBytes = pngBytes;
}
}