Phase 13: forge-1_12_2 ships blockstate/model JSON for non-cube models

BlockAssetExtractor#extractModels walks every active mod's own
jar/directory (Loader#getActiveModList's ModContainer#getSource, not
the classloader, since directory listing isn't a classloader
operation) for every blockstates/*.json and models/block/**/*.json
file, shipping all of them over a new batched block_models WS message
alongside the existing Phase 11 block_registry/block_textures dump.
Resolution of this data into real non-cube geometry is entirely
backend-side (see MCMapper-Backend's worker/src/models.rs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-10 01:19:36 +02:00
parent 47490309fe
commit 376d2d0507
6 changed files with 222 additions and 4 deletions
+25
View File
@@ -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/<modid>/blockstates/*.json` (flat, matching
vanilla's own layout — no subfolders expected) and `assets/<modid>/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`.
@@ -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<BlockTexture> 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<BlockModelFile> 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<BlockTexture> textures) {
}
@Override
public void sendBlockModels(List<BlockModelFile> files) {
}
@Override
public void setReadyListener(Runnable onReady) {
}
@@ -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 -&gt; api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
* mod -&gt; api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
* mod -&gt; api {"type":"block_models","entries":[{"kind":"blockstate","name":"botania:manapool","json":"..."},{"kind":"model","name":"botania:block/manapool","json":"..."}]}
* </pre>
*
* 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<BlockModelFile> 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<Object> entryList = new ArrayList<>();
for (BlockModelFile f : files.subList(start, end)) {
Map<String, Object> obj = new LinkedHashMap<>();
obj.put("kind", f.kind);
obj.put("name", f.name);
obj.put("json", f.json);
entryList.add(obj);
}
Map<String, Object> 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;
@@ -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/<modid>/blockstates/<path>.json} file, keyed by the
* block's own registry name) or {@code "model"} (a {@code assets/<modid>/models/block/<path>.json}
* file, keyed by {@code "<modid>:block/<path>"} — 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;
}
}
@@ -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 -&gt; registry-
@@ -23,10 +32,10 @@ import java.util.List;
* {@code assets/<modid>/textures/blocks/<path>.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/<modid>/blockstates/*.json} and {@code
* assets/<modid>/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<BlockModelFile> extractModels(Logger logger) {
List<BlockModelFile> 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<BlockModelFile> out) {
try (ZipFile zip = new ZipFile(jarFile)) {
Enumeration<? extends ZipEntry> 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<BlockModelFile> 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<BlockModelFile> 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;
}
}
}
@@ -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<BlockRegistryEntry> blockRegistry = BlockAssetExtractor.extractRegistry();
List<BlockTexture> blockTextures = BlockAssetExtractor.extractTextures(LOGGER);
List<BlockModelFile> blockModels = BlockAssetExtractor.extractModels(LOGGER);
connection.setReadyListener(() -> {
connection.sendBlockRegistry(blockRegistry);
connection.sendBlockTextures(blockTextures);
connection.sendBlockModels(blockModels);
});
connection.connect(config.backendUrl, config.serverToken);