diff --git a/README.md b/README.md index 93a84e3..5e11fb5 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,31 @@ matching this phase's Enigmatica-2-focused scope. See MCMapper-Backend's README backend currently does with this data (short version: stores it; per-server render-time resolution is explicitly deferred, documented there). +### Real (non-cube) block models (Phase 13) + +`BlockAssetExtractor#extractModels` ships every `assets//blockstates/*.json` (flat, matching +vanilla's own layout — no subfolders expected) and `assets//models/block/**/*.json` +(recursive — mods are free to nest these, e.g. a shared `models/block/base/` folder) file for every +currently-active mod, keyed the way MCMapper-Backend's `worker/src/models.rs` expects. Unlike +`extractTextures`, this can't use the classloader (there's no `ClassLoader` API to list a +directory's contents, only to read one known file by path) — it walks each mod's own source +instead, via `net.minecraftforge.fml.common.Loader#getActiveModList`'s `ModContainer#getSource`, +handling both shapes that can show up there: a packed jar (opened as a `ZipFile`) in production, or +a raw exploded directory when running from an IDE/dev environment. + +The mod deliberately ships **every** model file it finds under a mod's `models/block/` tree rather +than trying to work out which ones a given blockstate actually references — a blockstate's +`"model"` field (or another model's `"parent"`) can point at a path that doesn't match the +referencing block's own registry path 1:1 (e.g. several blocks sharing one base model), and the +mod has no JSON parser to resolve that itself. That resolution — parent-chain walking, texture- +variable substitution, picking a representative variant — is entirely backend-side (see +MCMapper-Backend's README's own Phase 13 section for what it does and doesn't handle, e.g. no +`multipart` blockstate support, no property-based variant selection). + +Sent as a new `block_models` WS message, batched 50/message like `block_textures`, from the same +ready-listener callback as the Phase 11 registry/texture dump. Verified via a real +`./gradlew :forge-1_12_2:compileJava` against the pinned legacy ForgeGradle toolchain. + ## 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 f03a45f..741f063 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,6 @@ package com.octoturge.mcmapper.common; +import com.octoturge.mcmapper.common.protocol.BlockModelFile; import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry; import com.octoturge.mcmapper.common.protocol.BlockTexture; import com.octoturge.mcmapper.common.protocol.DeltaEvent; @@ -54,6 +55,13 @@ public interface BackendConnection { */ void sendBlockTextures(List textures); + /** + * Sends this leaf's raw blockstate/model JSON dump (Phase 13) — see {@link + * BlockModelFile}'s javadoc. Implementations should batch calls the same way {@link + * #sendBlockTextures} does, for the same heavily-modded-pack reason. + */ + void sendBlockModels(List files); + /** * Registers a callback fired every time the connection successfully authenticates (including * after an automatic reconnect) — {@code connect()} itself is async (the real handshake @@ -112,6 +120,10 @@ public interface BackendConnection { public void sendBlockTextures(List textures) { } + @Override + public void sendBlockModels(List files) { + } + @Override public void setReadyListener(Runnable onReady) { } 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 432d8f5..e0c7094 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,7 @@ package com.octoturge.mcmapper.common; import com.octoturge.mcmapper.common.json.MiniJson; +import com.octoturge.mcmapper.common.protocol.BlockModelFile; import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry; import com.octoturge.mcmapper.common.protocol.BlockTexture; import com.octoturge.mcmapper.common.protocol.DeltaEvent; @@ -40,6 +41,7 @@ import java.util.function.Consumer; * * mod -> api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]} * mod -> api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]} + * mod -> api {"type":"block_models","entries":[{"kind":"blockstate","name":"botania:manapool","json":"..."},{"kind":"model","name":"botania:block/manapool","json":"..."}]} * * * Phase 3: {@code link_request} is sent by {@code /mcmapper link}; {@code chat} both directions @@ -83,6 +85,13 @@ import java.util.function.Consumer; * {@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. * + * Phase 13: {@code block_models} is sent the same way, once, alongside {@code block_registry}/ + * {@code block_textures} — see {@link BlockModelFile}'s javadoc for why the mod ships every model + * file it finds rather than trying to resolve blockstate-to-model references itself. + * {@link #sendBlockModels} batches the same way {@link #sendBlockTextures} does, for the same + * reason (blockstate/model JSON files, while individually small, can number in the thousands on + * a heavily-modded pack). + * * 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. @@ -352,6 +361,28 @@ public class DefaultBackendConnection implements BackendConnection { } } + private static final int BLOCK_MODEL_BATCH_SIZE = 50; + + @Override + public void sendBlockModels(List files) { + if (files.isEmpty() || !serverReady) return; + for (int start = 0; start < files.size(); start += BLOCK_MODEL_BATCH_SIZE) { + int end = Math.min(start + BLOCK_MODEL_BATCH_SIZE, files.size()); + List entryList = new ArrayList<>(); + for (BlockModelFile f : files.subList(start, end)) { + Map obj = new LinkedHashMap<>(); + obj.put("kind", f.kind); + obj.put("name", f.name); + obj.put("json", f.json); + entryList.add(obj); + } + Map msg = new LinkedHashMap<>(); + msg.put("type", "block_models"); + msg.put("entries", entryList); + sendRaw("block_models", msg); + } + } + @Override public void setReadyListener(Runnable onReady) { this.readyListener = onReady; diff --git a/common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockModelFile.java b/common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockModelFile.java new file mode 100644 index 0000000..d692114 --- /dev/null +++ b/common/src/main/java/com/octoturge/mcmapper/common/protocol/BlockModelFile.java @@ -0,0 +1,26 @@ +package com.octoturge.mcmapper.common.protocol; + +/** + * One raw blockstate or model JSON file, extracted by a leaf off its own classloader (Phase 13) — + * same classloader-is-already-there rationale as {@link BlockTexture}. {@code kind} is either + * {@code "blockstate"} (a {@code assets//blockstates/.json} file, keyed by the + * block's own registry name) or {@code "model"} (a {@code assets//models/block/.json} + * file, keyed by {@code ":block/"} — the same reference shape a blockstate's + * {@code "model"} field or another model's {@code "parent"} field uses to point at it). The mod + * ships every model file it finds under a mod's {@code models/block/} tree rather than trying to + * pick out just the ones a given blockstate needs — a blockstate can reference a model at a path + * that doesn't match the block's own registry path 1:1 (e.g. a shared base model reused by several + * blocks), and the mod has no JSON parser to work that out itself; that resolution is the + * backend's job (see MCMapper-Backend's {@code worker/src/models.rs}). + */ +public final class BlockModelFile { + public final String kind; + public final String name; + public final String json; + + public BlockModelFile(String kind, String name, String json) { + this.kind = kind; + this.name = name; + this.json = json; + } +} 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 index a17fc7e..f9fad6a 100644 --- 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 @@ -1,16 +1,25 @@ package com.octoturge.mcmapper.forge1122; +import com.octoturge.mcmapper.common.protocol.BlockModelFile; 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 net.minecraftforge.fml.common.Loader; +import net.minecraftforge.fml.common.ModContainer; import org.apache.logging.log4j.Logger; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.ArrayList; +import java.util.Enumeration; import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; /** * Phase 11: best-effort classloader extraction of block textures + the numeric-id -> registry- @@ -23,10 +32,10 @@ import java.util.List; * {@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. + * real blockstate/model JSON resolution — {@link #extractModels} (Phase 13) is what feeds that, + * on the backend side (see MCMapper-Backend's {@code worker/src/models.rs}). A block whose texture + * doesn't match this convention (most non-cube blocks) simply isn't found by {@link + * #extractTextures} and is skipped there — no worse than pre-Phase-11 behavior for that block. */ final class BlockAssetExtractor { private BlockAssetExtractor() { @@ -85,4 +94,116 @@ final class BlockAssetExtractor { return null; } } + + /** + * Phase 13: ships every {@code assets//blockstates/*.json} and {@code + * assets//models/block/**\/*.json} file for every currently-active mod, keyed the same + * way {@code worker/src/models.rs}'s {@code ModelRegistry::build} expects — see {@link + * BlockModelFile}'s javadoc for why this ships every model file rather than trying to resolve + * which ones a blockstate actually needs. Walks each mod's source (a jar in production, a raw + * exploded directory when running from an IDE/dev environment — {@code ModContainer#getSource} + * covers both) directly rather than the classloader, since listing a directory's contents + * (unlike reading one known file by path, as {@link #extractTextures} does) isn't something + * {@code ClassLoader#getResourceAsStream} can do. + */ + static List extractModels(Logger logger) { + List files = new ArrayList<>(); + for (ModContainer mod : Loader.instance().getActiveModList()) { + File source = mod.getSource(); + if (source == null) continue; + if (source.isFile()) { + extractModelsFromJar(source, mod.getModId(), files); + } else if (source.isDirectory()) { + extractModelsFromDirectory(source, mod.getModId(), files); + } + } + logger.info("MCMapper (1.12.2 leaf) extracted " + files.size() + + " blockstate/model JSON files for Phase 13 non-cube model rendering"); + return files; + } + + private static void extractModelsFromJar(File jarFile, String modid, List out) { + try (ZipFile zip = new ZipFile(jarFile)) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.isDirectory()) continue; + ModelPath parsed = classify(modid, entry.getName()); + if (parsed == null) continue; + byte[] json = readZipEntry(zip, entry); + if (json != null) out.add(new BlockModelFile(parsed.kind, parsed.name, new String(json, StandardCharsets.UTF_8))); + } + } catch (IOException e) { + // Best-effort, matching readTexture's convention — skip this mod's models entirely. + } + } + + private static byte[] readZipEntry(ZipFile zip, ZipEntry entry) { + try (InputStream in = zip.getInputStream(entry)) { + 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; + } + } + + private static void extractModelsFromDirectory(File modRoot, String modid, List out) { + File assetsDir = new File(modRoot, "assets/" + modid); + if (assetsDir.isDirectory()) walkDirectory(assetsDir, "assets/" + modid + "/", modid, out); + } + + private static void walkDirectory(File dir, String relPrefix, String modid, List out) { + File[] children = dir.listFiles(); + if (children == null) return; + for (File child : children) { + String rel = relPrefix + child.getName(); + if (child.isDirectory()) { + walkDirectory(child, rel + "/", modid, out); + continue; + } + ModelPath parsed = classify(modid, rel); + if (parsed == null) continue; + try { + byte[] json = Files.readAllBytes(child.toPath()); + out.add(new BlockModelFile(parsed.kind, parsed.name, new String(json, StandardCharsets.UTF_8))); + } catch (IOException e) { + // Best-effort — skip this one file. + } + } + } + + /** + * Classifies one archive/directory entry path (e.g. {@code "assets/botania/blockstates/ + * manapool.json"}) into a wire-ready {@code (kind, name)} pair, or {@code null} if it's not a + * blockstate/model JSON file for this mod. Blockstates are matched flat (no subfolders, matching + * vanilla's own layout); models are matched recursively under {@code models/block/} since mods + * are free to nest them (e.g. a shared {@code models/block/base/} folder). + */ + private static ModelPath classify(String modid, String entryPath) { + String prefix = "assets/" + modid + "/"; + if (!entryPath.startsWith(prefix) || !entryPath.endsWith(".json")) return null; + String rest = entryPath.substring(prefix.length()); + if (rest.startsWith("blockstates/") && rest.indexOf('/', "blockstates/".length()) < 0) { + String path = rest.substring("blockstates/".length(), rest.length() - ".json".length()); + return new ModelPath("blockstate", modid + ":" + path); + } + if (rest.startsWith("models/block/")) { + String path = rest.substring("models/block/".length(), rest.length() - ".json".length()); + return new ModelPath("model", modid + ":block/" + path); + } + return null; + } + + private static final class ModelPath { + final String kind; + final String name; + + ModelPath(String kind, String name) { + this.kind = kind; + this.name = name; + } + } } 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 f39396f..771dc19 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,7 @@ 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.BlockModelFile; import com.octoturge.mcmapper.common.protocol.BlockRegistryEntry; import com.octoturge.mcmapper.common.protocol.BlockTexture; import com.octoturge.mcmapper.common.protocol.DeltaEvent; @@ -114,9 +115,11 @@ public class MCMapperMod { // silently no-op). List blockRegistry = BlockAssetExtractor.extractRegistry(); List blockTextures = BlockAssetExtractor.extractTextures(LOGGER); + List blockModels = BlockAssetExtractor.extractModels(LOGGER); connection.setReadyListener(() -> { connection.sendBlockRegistry(blockRegistry); connection.sendBlockTextures(blockTextures); + connection.sendBlockModels(blockModels); }); connection.connect(config.backendUrl, config.serverToken);