From 47490309fee2dbd4ae10fd12efd0fe1755468d0d Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sun, 9 Aug 2026 23:20:53 +0200 Subject: [PATCH] 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. --- README.md | 22 +++++ .../mcmapper/common/BackendConnection.java | 38 ++++++++ .../common/DefaultBackendConnection.java | 56 ++++++++++++ .../common/protocol/BlockRegistryEntry.java | 19 ++++ .../common/protocol/BlockTexture.java | 20 +++++ .../forge1122/BlockAssetExtractor.java | 88 +++++++++++++++++++ .../mcmapper/forge1122/MCMapperMod.java | 17 ++++ 7 files changed, 260 insertions(+) create mode 100644 common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockRegistryEntry.java create mode 100644 common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockTexture.java create mode 100644 forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/BlockAssetExtractor.java diff --git a/README.md b/README.md index 0d22ef1..93a84e3 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,28 @@ to web viewers is a separate, independent per-server admin setting on the backen MCMapper-Backend's README) — a server operator can track positions server-side without exposing them publicly, or vice versa. +### Real block textures (Phase 11) + +Forge doesn't split client/server jars, so a dedicated server's own classpath already has every +loaded mod's `assets//textures/blocks/*.png` (or `textures/block/` on newer conventions), +sitting there unused server-side. `forge-1_12_2` (this project's primary, most-modded target — +Enigmatica 2) reads them once at server-starting time via `BlockAssetExtractor`: `Block.REGISTRY` +gives every block's numeric id + registry name (same source as the existing column/section wire +encoding), and each block's texture is guessed by a best-effort *convention* match — the registry +name's path segment tried as a texture filename — not a real blockstate/model JSON resolution +(that's Phase 12's job; a block whose model doesn't follow the convention is just skipped, no +worse than before this phase). The registry mapping (`block_registry`) and extracted PNGs +(`block_textures`, batched 50/message) are sent to the backend once, right after the WS handshake +completes — via `BackendConnection#setReadyListener`, since `connect()` is async and a naive send +right after calling it would silently no-op before the handshake lands. + +`forge-1_7_10` and `neoforge-26_1` don't implement extraction yet — `sendBlockRegistry`/ +`sendBlockTextures`/`setReadyListener` live on the shared `BackendConnection` interface (so either +leaf can adopt them later with no protocol change) but only `forge-1_12_2` calls them so far, +matching this phase's Enigmatica-2-focused scope. See MCMapper-Backend's README for what the +backend currently does with this data (short version: stores it; per-server render-time +resolution is explicitly deferred, documented there). + ## Attribution See `THIRD_PARTY_NOTICES.md`. diff --git a/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java b/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java index 5eb794f..f03a45f 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java @@ -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 players); + /** + * Sends this leaf's numeric-blockId -> 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 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 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 players) { } + @Override + public void sendBlockRegistry(List entries) { + } + + @Override + public void sendBlockTextures(List textures) { + } + + @Override + public void setReadyListener(Runnable onReady) { + } + @Override public void setChatListener(ChatListener listener) { } diff --git a/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java b/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java index 53e1c5b..432d8f5 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java @@ -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 -> mod {"type":"waypoint_share","name":"...","x":..,"y":..,"z":..,"dimension":..,"color":"#RRGGBB","format":"journeymap"|"xaero"} * * mod -> api {"type":"player_positions","dimension":0,"players":[{"uuid":"...","username":"...","x":..,"y":..,"z":..}]} + * + * mod -> api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]} + * mod -> api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]} * * * 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 logInfo, Consumer 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 entries) { + if (entries.isEmpty() || !serverReady) return; + List entryList = new ArrayList<>(); + for (BlockRegistryEntry e : entries) { + Map obj = new LinkedHashMap<>(); + obj.put("id", (double) e.id); + obj.put("name", e.name); + entryList.add(obj); + } + Map msg = new LinkedHashMap<>(); + msg.put("type", "block_registry"); + msg.put("entries", entryList); + sendRaw("block_registry", msg); + } + + @Override + public void sendBlockTextures(List 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 textureList = new ArrayList<>(); + for (BlockTexture t : textures.subList(start, end)) { + Map obj = new LinkedHashMap<>(); + obj.put("name", t.name); + obj.put("dataBase64", Base64.getEncoder().encodeToString(t.pngBytes)); + textureList.add(obj); + } + Map 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; diff --git a/common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockRegistryEntry.java b/common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockRegistryEntry.java new file mode 100644 index 0000000..5f3aeec --- /dev/null +++ b/common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockRegistryEntry.java @@ -0,0 +1,19 @@ +package com.octoturge.mcmapper.common.protocol; + +/** + * One entry of a leaf's numeric-blockId -> 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; + } +} diff --git a/common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockTexture.java b/common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockTexture.java new file mode 100644 index 0000000..54aa7d5 --- /dev/null +++ b/common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockTexture.java @@ -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//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; + } +} diff --git a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/BlockAssetExtractor.java b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/BlockAssetExtractor.java new file mode 100644 index 0000000..a17fc7e --- /dev/null +++ b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/BlockAssetExtractor.java @@ -0,0 +1,88 @@ +package com.octoturge.mcmapper.forge1122; + +import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry; +import com.octoturge.mcmapper.common.protocol.BlockTexture; +import net.minecraft.block.Block; +import net.minecraft.util.ResourceLocation; +import org.apache.logging.log4j.Logger; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Phase 11: best-effort classloader extraction of block textures + the numeric-id -> registry- + * name mapping needed to make sense of them on the backend (see {@link BlockRegistryEntry}/ + * {@link BlockTexture}'s javadocs). Forge doesn't split client/server jars, so every loaded mod's + * texture assets are already sitting on this dedicated server's own classpath — {@code + * Block.REGISTRY} gives every registered block's numeric id + registry name (same {@code + * Block.getIdFromBlock} used for the existing column/section wire encoding, see + * Forge1122ChunkAdapter), and the JVM's own merged mod classloader can load + * {@code assets//textures/blocks/.png} directly with no jar-file bookkeeping. + * + * This is a *convention* match (the registry name's path segment as the texture filename), not a + * real blockstate/model JSON resolution — that's Phase 12's job (see the plan's phase list). A + * block whose model uses a differently-named/multi-texture layout (most non-cube blocks) simply + * isn't found here and is skipped — no worse than pre-Phase-11 behavior for that block, just not + * improved yet. + */ +final class BlockAssetExtractor { + private BlockAssetExtractor() { + } + + static List extractRegistry() { + List entries = new ArrayList<>(); + for (Block block : Block.REGISTRY) { + ResourceLocation name = block.getRegistryName(); + if (name == null) continue; + entries.add(new BlockRegistryEntry(Block.getIdFromBlock(block), name.toString())); + } + return entries; + } + + static List extractTextures(Logger logger) { + ClassLoader classLoader = BlockAssetExtractor.class.getClassLoader(); + List textures = new ArrayList<>(); + int attempted = 0; + for (Block block : Block.REGISTRY) { + ResourceLocation name = block.getRegistryName(); + if (name == null) continue; + attempted++; + // ResourceLocation's domain/path accessor name varies by MCP mapping version (e.g. + // getResourceDomain/getResourcePath vs. getNamespace/getPath) — toString()'s + // "modid:path" shape is stable across all of them, so split on that instead. + String full = name.toString(); + int colon = full.indexOf(':'); + String modid = colon >= 0 ? full.substring(0, colon) : full; + String path = colon >= 0 ? full.substring(colon + 1) : full; + byte[] png = readTexture(classLoader, modid, path); + if (png != null) textures.add(new BlockTexture(full, png)); + } + logger.info("MCMapper (1.12.2 leaf) extracted " + textures.size() + "/" + attempted + + " block textures for Phase 11 texture-averaged rendering (best-effort convention match)"); + return textures; + } + + private static byte[] readTexture(ClassLoader classLoader, String modid, String path) { + // 1.12.2 uses textures/blocks/ (plural) — see the backend's block_names.rs doc comment + // for the 1.13+ textures/block/ rename, tried second in case a mod ships modern-style paths. + byte[] png = readResource(classLoader, "assets/" + modid + "/textures/blocks/" + path + ".png"); + if (png != null) return png; + return readResource(classLoader, "assets/" + modid + "/textures/block/" + path + ".png"); + } + + private static byte[] readResource(ClassLoader classLoader, String resourcePath) { + try (InputStream in = classLoader.getResourceAsStream(resourcePath)) { + if (in == null) return null; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) != -1) out.write(buf, 0, n); + return out.toByteArray(); + } catch (IOException e) { + return null; + } + } +} 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 42b7826..f39396f 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 @@ -4,6 +4,8 @@ import com.octoturge.mcmapper.common.ChunkAdapter; import com.octoturge.mcmapper.common.DefaultBackendConnection; import com.octoturge.mcmapper.common.ReconciliationScheduler; import com.octoturge.mcmapper.common.config.MapperConfig; +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.PlayerPosition; import com.octoturge.mcmapper.common.protocol.SectionData; @@ -102,6 +104,21 @@ public class MCMapperMod { mcServer = event.getServer(); connection = new DefaultBackendConnection(LOGGER::info, LOGGER::warn); + + // Phase 11: extracted once, synchronously, here — Block.REGISTRY is already fully + // populated by server-starting time (all mod block registration happens during loading, + // well before this event fires), and it's a pure classpath read (no network), so this + // doesn't meaningfully delay startup even on a heavily-modded pack. Sent via the ready + // listener below rather than right after connect() (which is async — see + // BackendConnection#setReadyListener's javadoc for why a naive immediate send would + // silently no-op). + List blockRegistry = BlockAssetExtractor.extractRegistry(); + List blockTextures = BlockAssetExtractor.extractTextures(LOGGER); + connection.setReadyListener(() -> { + connection.sendBlockRegistry(blockRegistry); + connection.sendBlockTextures(blockTextures); + }); + connection.connect(config.backendUrl, config.serverToken); LOGGER.info("MCMapper (1.12.2 leaf) connecting to " + config.backendUrl);