897 lines
43 KiB
Java
897 lines
43 KiB
Java
import com.google.gson.Gson;
|
|
import com.google.gson.GsonBuilder;
|
|
import com.google.gson.JsonArray;
|
|
import com.google.gson.JsonObject;
|
|
import java.io.FileWriter;
|
|
import java.lang.reflect.Field;
|
|
import java.lang.reflect.Method;
|
|
import java.lang.reflect.Modifier;
|
|
import java.util.ArrayList;
|
|
import java.util.IdentityHashMap;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.TreeMap;
|
|
import net.minecraft.SharedConstants;
|
|
import net.minecraft.client.model.Model;
|
|
import net.minecraft.client.model.geom.EntityModelSet;
|
|
import net.minecraft.client.model.geom.LayerDefinitions;
|
|
import net.minecraft.client.model.geom.ModelLayerLocation;
|
|
import net.minecraft.client.model.geom.ModelPart;
|
|
import net.minecraft.client.model.geom.builders.CubeDefinition;
|
|
import net.minecraft.client.model.geom.builders.LayerDefinition;
|
|
import net.minecraft.client.model.geom.builders.PartDefinition;
|
|
import net.minecraft.client.renderer.entity.EntityRenderer;
|
|
import net.minecraft.client.renderer.entity.EntityRendererProvider;
|
|
import net.minecraft.core.Direction;
|
|
import net.minecraft.core.registries.BuiltInRegistries;
|
|
import net.minecraft.server.Bootstrap;
|
|
import net.minecraft.world.entity.EntityType;
|
|
|
|
/**
|
|
* Dumps the vanilla Java entity models straight from an (unobfuscated, 26.x+) Minecraft CLIENT jar into
|
|
* the CEM-template JSON layout PixelPics loads ({@code {"models": {name: {"model": "<jem json>"}}}}).
|
|
*
|
|
* <p>Unlike the third-party CEM template set (rest geometry only), every entity model is instantiated
|
|
* through its real renderer and posed by its real {@code setupAnim} with a default (idle, adult, not
|
|
* aggressive) render state — so zombie arms are raised, spider legs splayed, alternate-state parts
|
|
* (rolled-up armadillo, crossed/straight illager arms, chests) carry their vanilla visibility, and new
|
|
* mobs of the jar's version are included automatically.
|
|
*
|
|
* <p>Output per entity type key: {@code <type>} (main model) plus {@code <type>/<extra>} for the
|
|
* renderer's additional models and render-layer models (outer layers, armor sets, saddles, …).
|
|
* Part coordinates are ABSOLUTE in the CEM frame (x negated, y up with the ground at 24, z kept);
|
|
* every part carries its absolute pivot ({@code "pivot"}), so the jem is flagged {@code "absolute": true}.
|
|
*
|
|
* <p>Run (classpath = client jar + its libraries from the version json):
|
|
* {@code java -cp <mc-classpath> tools/VanillaModelDump.java <out.json>}
|
|
*/
|
|
public class VanillaModelDump {
|
|
|
|
static final Map<Object, ModelLayerLocation> BAKED = new IdentityHashMap<>();
|
|
static Map<ModelLayerLocation, LayerDefinition> roots;
|
|
|
|
public static void main(String[] args) throws Exception {
|
|
SharedConstants.tryDetectVersion();
|
|
Bootstrap.bootStrap();
|
|
roots = LayerDefinitions.createRoots();
|
|
EntityModelSet set = new EntityModelSet(roots) {
|
|
@Override
|
|
public ModelPart bakeLayer(ModelLayerLocation loc) {
|
|
ModelPart p = super.bakeLayer(loc);
|
|
BAKED.put(p, loc);
|
|
return p;
|
|
}
|
|
};
|
|
EntityRendererProvider.Context ctx =
|
|
new EntityRendererProvider.Context(null, null, null, null, null, set, null, null, null, null, null);
|
|
|
|
Field provField = Class.forName("net.minecraft.client.renderer.entity.EntityRenderers")
|
|
.getDeclaredField("PROVIDERS");
|
|
provField.setAccessible(true);
|
|
@SuppressWarnings("unchecked")
|
|
Map<EntityType<?>, EntityRendererProvider<?>> providers =
|
|
(Map<EntityType<?>, EntityRendererProvider<?>>) provField.get(null);
|
|
|
|
Map<String, JsonObject> out = new TreeMap<>();
|
|
Map<String, JsonObject> entities = new TreeMap<>();
|
|
List<String> failed = new ArrayList<>();
|
|
for (Map.Entry<EntityType<?>, EntityRendererProvider<?>> e : providers.entrySet()) {
|
|
String key = BuiltInRegistries.ENTITY_TYPE.getKey(e.getKey()).getPath();
|
|
EntityRenderer<?, ?> r;
|
|
try {
|
|
r = e.getValue().create(ctx);
|
|
} catch (Throwable t) {
|
|
failed.add(key + " (renderer: " + t + ")");
|
|
continue;
|
|
}
|
|
Object state;
|
|
try {
|
|
state = r.createRenderState();
|
|
} catch (Throwable t) {
|
|
state = null;
|
|
}
|
|
idleTweaks(state);
|
|
Map<String, Model<?>> found = new LinkedHashMap<>();
|
|
Map<String, String> textures = new LinkedHashMap<>();
|
|
// The renderer's current model is the main one, whatever field else also holds it (bigModel, …).
|
|
if (fieldValue(r, "model") instanceof Model<?> current) found.put("model", current);
|
|
collectModels(r, "", found, 0);
|
|
Object layers = fieldValue(r, "layers");
|
|
if (layers instanceof List<?> ls) {
|
|
for (Object layer : ls) collectLayer(layer, found, textures);
|
|
}
|
|
JsonObject info = entityInfo(r, state);
|
|
addLayerInfo(info, layers, r);
|
|
addTransform(info, r);
|
|
Model<?> main = found.containsKey("model") ? found.get("model") : found.get("adultModel");
|
|
if (main != null) addPoseVariants(info, main, r);
|
|
entities.put(key, info);
|
|
emit(key, found, textures, state, out, failed);
|
|
}
|
|
// Players & mannequins use the avatar renderers (one per skin model type: wide/slim).
|
|
Map<?, ?> avatars = (Map<?, ?>) Class.forName("net.minecraft.client.renderer.entity.EntityRenderers")
|
|
.getMethod("createAvatarRenderers", EntityRendererProvider.Context.class)
|
|
.invoke(null, ctx);
|
|
for (Map.Entry<?, ?> a : avatars.entrySet()) {
|
|
EntityRenderer<?, ?> r = (EntityRenderer<?, ?>) a.getValue();
|
|
String key = String.valueOf(a.getKey()).toLowerCase().equals("slim") ? "player_slim" : "player";
|
|
Map<String, Model<?>> found = new LinkedHashMap<>();
|
|
Map<String, String> textures = new LinkedHashMap<>();
|
|
collectModels(r, "", found, 0);
|
|
Object layers = fieldValue(r, "layers");
|
|
if (layers instanceof List<?> ls) {
|
|
for (Object layer : ls) collectLayer(layer, found, textures);
|
|
}
|
|
JsonObject info = new JsonObject();
|
|
addLayerInfo(info, layers, r);
|
|
addTransform(info, r);
|
|
Model<?> main = found.get("model");
|
|
if (main != null) addPoseVariants(info, main, r);
|
|
entities.put(key, info);
|
|
emit(key, found, textures, r.createRenderState(), out, failed);
|
|
}
|
|
// Every layer definition in its rest pose as "layer/<model>#<layer>" (special item models such as the
|
|
// shield, block-entity models, …) — same absolute CEM frame as the entity models.
|
|
for (Map.Entry<ModelLayerLocation, LayerDefinition> le : roots.entrySet()) {
|
|
String name = "layer/" + le.getKey().model().getPath() + "#" + le.getKey().layer();
|
|
try {
|
|
ModelPart baked = le.getValue().bakeRoot();
|
|
PartDefinition rootDef = ((net.minecraft.client.model.geom.builders.MeshDefinition)
|
|
fieldValue(le.getValue(), "mesh"))
|
|
.getRoot();
|
|
Object mat = fieldValue(le.getValue(), "material");
|
|
JsonObject jem = new JsonObject();
|
|
JsonArray ts = new JsonArray();
|
|
ts.add((int) fieldValue(mat, "xTexSize"));
|
|
ts.add((int) fieldValue(mat, "yTexSize"));
|
|
jem.add("textureSize", ts);
|
|
jem.addProperty("absolute", true);
|
|
JsonArray parts = new JsonArray();
|
|
parts.add(part("root", rootDef, baked, 0, 0, 0));
|
|
jem.add("models", parts);
|
|
JsonObject entry = new JsonObject();
|
|
entry.addProperty("model", new Gson().toJson(jem));
|
|
out.putIfAbsent(name, entry);
|
|
} catch (Throwable t) {
|
|
failed.add(name + " (layer: " + t + ")");
|
|
}
|
|
}
|
|
// Block-entity models posed by their own setupAnim with the static state the vanilla
|
|
// BlockEntityRenderer uses ("posed/<layer>"): skulls at animation 0 (dragon jaw, piglin ears), banner
|
|
// flags at wave phase 0 (item/static pose), the lectern's open book (LecternRenderer.BOOK_STATE).
|
|
// Rest-pose-only models (chest, bell, shulker box, pot, conduit) use "layer/<layer>" directly.
|
|
posedBlockEntity(out, failed, set, net.minecraft.client.model.geom.ModelLayers.DRAGON_SKULL,
|
|
m -> new net.minecraft.client.model.object.skull.DragonHeadModel(m),
|
|
new net.minecraft.client.model.object.skull.SkullModelBase.State());
|
|
posedBlockEntity(out, failed, set, net.minecraft.client.model.geom.ModelLayers.PIGLIN_HEAD,
|
|
m -> new net.minecraft.client.model.object.skull.PiglinHeadModel(m),
|
|
new net.minecraft.client.model.object.skull.SkullModelBase.State());
|
|
posedBlockEntity(out, failed, set, net.minecraft.client.model.geom.ModelLayers.STANDING_BANNER_FLAG,
|
|
m -> new net.minecraft.client.model.object.banner.BannerFlagModel(m), 0.0F);
|
|
posedBlockEntity(out, failed, set, net.minecraft.client.model.geom.ModelLayers.WALL_BANNER_FLAG,
|
|
m -> new net.minecraft.client.model.object.banner.BannerFlagModel(m), 0.0F);
|
|
try {
|
|
Field bookState = Class.forName("net.minecraft.client.renderer.blockentity.LecternRenderer")
|
|
.getDeclaredField("BOOK_STATE");
|
|
bookState.setAccessible(true);
|
|
posedBlockEntity(out, failed, set, net.minecraft.client.model.geom.ModelLayers.BOOK,
|
|
m -> new net.minecraft.client.model.object.book.BookModel(m), bookState.get(null));
|
|
} catch (ReflectiveOperationException e) {
|
|
failed.add("posed/book (" + e + ")");
|
|
}
|
|
JsonObject root = new JsonObject();
|
|
root.addProperty("version", SharedConstants.getCurrentVersion().name());
|
|
JsonObject models = new JsonObject();
|
|
out.forEach(models::add);
|
|
root.add("models", models);
|
|
JsonObject ents = new JsonObject();
|
|
entities.forEach(ents::add);
|
|
root.add("entities", ents);
|
|
root.add("variants", registryVariants());
|
|
try (FileWriter w = new FileWriter(args.length > 0 ? args[0] : "vanilla_entity_models.json")) {
|
|
new GsonBuilder().create().toJson(root, w);
|
|
}
|
|
System.out.println("models: " + out.size());
|
|
for (String f : failed) System.out.println("FAILED " + f);
|
|
}
|
|
|
|
/**
|
|
* The renderer's layers: {@code layers: {LayerClass: drawnByDefault}} — whether the layer submits anything
|
|
* for the default (idle, adult, untamed, unequipped, …) render state, measured by running its real
|
|
* {@code submit} against a recording collector — plus {@code humanoidArmor: true} when one of them is a
|
|
* HumanoidArmorLayer (the entity wears humanoid armor).
|
|
*/
|
|
static void addLayerInfo(JsonObject info, Object layers, EntityRenderer<?, ?> r) {
|
|
if (!(layers instanceof List<?> ls) || ls.isEmpty()) return;
|
|
JsonObject out = new JsonObject();
|
|
for (Object layer : ls) {
|
|
Class<?> lc = layer.getClass();
|
|
while (lc.getSimpleName().isEmpty()) lc = lc.getSuperclass(); // anonymous layer -> its base class
|
|
String name = lc.getSimpleName();
|
|
if (layer instanceof net.minecraft.client.renderer.entity.layers.HumanoidArmorLayer<?, ?, ?>) {
|
|
info.addProperty("humanoidArmor", true);
|
|
}
|
|
Boolean drawn = layerDrawsByDefault(layer, r);
|
|
if (drawn == null) {
|
|
System.out.println("layer " + name + " (" + r.getClass().getSimpleName() + "): submit failed");
|
|
drawn = false;
|
|
}
|
|
if (!out.has(name) || drawn) out.addProperty(name, drawn);
|
|
}
|
|
info.add("layers", out);
|
|
}
|
|
|
|
/** True/false = the layer submits / doesn't submit geometry for the default state; null = submit threw. */
|
|
static Boolean layerDrawsByDefault(Object layer, EntityRenderer<?, ?> r) {
|
|
Object state;
|
|
try {
|
|
state = r.createRenderState();
|
|
} catch (Throwable t) {
|
|
return null;
|
|
}
|
|
idleTweaks(state);
|
|
boolean[] submitted = {false};
|
|
Object proxy = recordingCollector(m -> {
|
|
if (m.getName().startsWith("submit")) submitted[0] = true;
|
|
});
|
|
Method submit = null;
|
|
for (Method m : layer.getClass().getMethods()) {
|
|
if (m.getName().equals("submit") && m.getParameterCount() == 6 && !m.isBridge()) submit = m;
|
|
}
|
|
if (submit == null) return null;
|
|
try {
|
|
submit.invoke(layer, new com.mojang.blaze3d.vertex.PoseStack(), proxy, 0xF000F0, state, 0f, 0f);
|
|
} catch (Throwable t) {
|
|
return submitted[0] ? Boolean.TRUE : null;
|
|
}
|
|
return submitted[0];
|
|
}
|
|
|
|
/** A SubmitNodeCollector proxy that reports every call and otherwise does nothing. */
|
|
static Object recordingCollector(java.util.function.Consumer<Method> onCall) {
|
|
Class<?> collector;
|
|
try {
|
|
collector = Class.forName("net.minecraft.client.renderer.SubmitNodeCollector");
|
|
} catch (ClassNotFoundException e) {
|
|
throw new IllegalStateException(e);
|
|
}
|
|
return java.lang.reflect.Proxy.newProxyInstance(
|
|
VanillaModelDump.class.getClassLoader(), new Class<?>[] {collector}, (px, m, a) -> {
|
|
if (m.getName().equals("order")) return px;
|
|
onCall.accept(m);
|
|
Class<?> rt = m.getReturnType();
|
|
if (rt == boolean.class) return false;
|
|
if (rt.isPrimitive() && rt != void.class) return 0;
|
|
return null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The built-in data-driven mob variants ({@code data/minecraft/<type>_variant/<key>.json}, cat/wolf/frog/
|
|
* cow/pig/chicken/zombie_nautilus …) with their client assets: {@code variants: {<type>: {<key>: {asset_id,
|
|
* baby_asset_id, assets{wild,tame,angry}, baby_assets{…}, model}}}} (spawn conditions dropped).
|
|
*/
|
|
static JsonObject registryVariants() throws java.io.IOException {
|
|
JsonObject out = new JsonObject();
|
|
java.net.URL jar = SharedConstants.class.getProtectionDomain().getCodeSource().getLocation();
|
|
java.util.regex.Pattern p =
|
|
java.util.regex.Pattern.compile("data/minecraft/([a-z0-9_]+)_variant/([a-z0-9_/.-]+)\\.json");
|
|
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(new java.io.File(jar.getPath()))) {
|
|
List<? extends java.util.zip.ZipEntry> entries =
|
|
zf.stream().sorted(java.util.Comparator.comparing(java.util.zip.ZipEntry::getName)).toList();
|
|
for (java.util.zip.ZipEntry ze : entries) {
|
|
java.util.regex.Matcher m = p.matcher(ze.getName());
|
|
if (!m.matches()) continue;
|
|
String type = m.group(1);
|
|
if (type.endsWith("_sound") || type.equals("painting")) continue; // not mob textures
|
|
JsonObject v;
|
|
try (java.io.Reader rd = new java.io.InputStreamReader(zf.getInputStream(ze))) {
|
|
v = com.google.gson.JsonParser.parseReader(rd).getAsJsonObject();
|
|
}
|
|
JsonObject keep = new JsonObject();
|
|
for (String k : new String[] {"asset_id", "baby_asset_id", "assets", "baby_assets", "model"}) {
|
|
if (v.has(k)) keep.add(k, v.get(k));
|
|
}
|
|
if (keep.isEmpty()) continue;
|
|
if (!out.has(type)) out.add(type, new JsonObject());
|
|
out.getAsJsonObject(type).add(m.group(2), keep);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** One block-entity model posed with {@code state} (see main), stored as {@code posed/<model>#<layer>}. */
|
|
static void posedBlockEntity(
|
|
Map<String, JsonObject> out,
|
|
List<String> failed,
|
|
EntityModelSet set,
|
|
ModelLayerLocation loc,
|
|
java.util.function.Function<ModelPart, Model<?>> factory,
|
|
Object state) {
|
|
String name = "posed/" + loc.model().getPath() + "#" + loc.layer();
|
|
try {
|
|
JsonObject jem = dump(factory.apply(set.bakeLayer(loc)), state, name, failed);
|
|
if (jem == null) return;
|
|
JsonObject entry = new JsonObject();
|
|
entry.addProperty("model", new Gson().toJson(jem));
|
|
out.put(name, entry);
|
|
} catch (Throwable t) {
|
|
failed.add(name + " (" + t + ")");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Small, name-based tweaks so the default render state shows the typical idle look: illagers stand
|
|
* with crossed arms (their peaceful pose) instead of the empty-handed "neutral" pose.
|
|
*/
|
|
static void idleTweaks(Object state) {
|
|
if (state == null) return;
|
|
for (String sizeField : new String[] {"size"}) {
|
|
try {
|
|
Field f = state.getClass().getField(sizeField);
|
|
if (f.getType() == int.class && f.getInt(state) == 0) f.setInt(state, 1);
|
|
if (f.getType() == float.class && f.getFloat(state) == 0) f.setFloat(state, 1);
|
|
} catch (Throwable ignored) {
|
|
}
|
|
}
|
|
try {
|
|
Field f = state.getClass().getField("armPose");
|
|
if (f.getType().isEnum()) {
|
|
for (Object c : f.getType().getEnumConstants())
|
|
if (((Enum<?>) c).name().equals("CROSSED")) f.set(state, c);
|
|
}
|
|
} catch (Throwable ignored) {
|
|
}
|
|
}
|
|
|
|
/** Writes every collected model of one entity type; adult/normal/plain "model" fields become the main model. */
|
|
static void emit(
|
|
String key,
|
|
Map<String, Model<?>> found,
|
|
Map<String, String> textures,
|
|
Object state,
|
|
Map<String, JsonObject> out,
|
|
List<String> failed) {
|
|
for (Map.Entry<String, Model<?>> m : found.entrySet()) {
|
|
String f = m.getKey();
|
|
String name =
|
|
switch (f) {
|
|
case "model", "adultModel", "normalModel" -> key;
|
|
case "babyModel" -> key + "/baby";
|
|
default -> key + "/" + f;
|
|
};
|
|
if (out.containsKey(name)) continue;
|
|
JsonObject jem = dump(m.getValue(), state, name, failed);
|
|
if (jem != null) {
|
|
String tex = textures.get(f);
|
|
if (tex != null) jem.addProperty("texture", tex);
|
|
JsonObject entry = new JsonObject();
|
|
entry.addProperty("model", new Gson().toJson(jem));
|
|
out.put(name, entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A render layer's models (prefixed by the layer class; equipment layers by their equipment layer type,
|
|
* e.g. {@code equip.horse_saddle}) plus the texture the layer draws them with, when it holds one.
|
|
*/
|
|
static void collectLayer(Object layer, Map<String, Model<?>> found, Map<String, String> textures) {
|
|
String prefix = layer.getClass().getSimpleName() + ".";
|
|
Object type = fieldValue(layer, "layer");
|
|
if (type instanceof Enum<?> en) prefix = "equip." + en.name().toLowerCase() + ".";
|
|
Map<String, Model<?>> mine = new LinkedHashMap<>();
|
|
collectModels(layer, prefix, mine, 0);
|
|
// Texture identifiers held by the layer (static constants or instance fields): baby vs adult.
|
|
String adult = null, baby = null;
|
|
for (Class<?> c = layer.getClass(); c != null && c != Object.class; c = c.getSuperclass()) {
|
|
for (Field fl : c.getDeclaredFields()) {
|
|
if (!fl.getType().getSimpleName().equals("Identifier")) continue;
|
|
try {
|
|
fl.setAccessible(true);
|
|
Object v = fl.get(Modifier.isStatic(fl.getModifiers()) ? null : layer);
|
|
if (v == null) continue;
|
|
String path = v.toString().replaceFirst("^minecraft:", "")
|
|
.replaceFirst("^textures/", "")
|
|
.replaceFirst("\\.png$", "");
|
|
if (fl.getName().toLowerCase().contains("baby")) {
|
|
if (baby == null) baby = path;
|
|
} else if (adult == null) adult = path;
|
|
} catch (Throwable ignored) {
|
|
}
|
|
}
|
|
}
|
|
for (Map.Entry<String, Model<?>> m : mine.entrySet()) {
|
|
if (found.containsValue(m.getValue())) continue;
|
|
found.put(m.getKey(), m.getValue());
|
|
String tex = m.getKey().toLowerCase().contains("baby") ? baby : adult;
|
|
if (tex != null) textures.put(m.getKey(), tex);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs the renderer's real {@code submit} (yaw 0, pitch 0) against a recording collector and returns the
|
|
* pose matrix (column-major 4x4, Java model units -> entity-local blocks) of its first model submission:
|
|
* the renderer's own translate/rotate/scale (end-crystal float, minecart offsets, wither x2, …), so no
|
|
* per-entity placement table is needed.
|
|
*/
|
|
/**
|
|
* Stores the renderer transform at yaw=pitch=0 ({@code transform}, column-major 4x4) plus how yaw and
|
|
* pitch enter it, measured by re-rendering at yaw 90 / pitch 90: {@code yawSign} (+1/-1, 0 = ignores yaw)
|
|
* and {@code pitchAxis} (world-frame axis for +pitch; absent = ignores pitch).
|
|
*/
|
|
static void addTransform(JsonObject info, EntityRenderer<?, ?> r) {
|
|
float[] t0 = captureTransform(r, 0, 0);
|
|
if (t0 == null) return;
|
|
JsonArray ta = new JsonArray();
|
|
for (float v : t0) ta.add(Math.round(v * 100000f) / 100000f);
|
|
info.add("transform", ta);
|
|
org.joml.Matrix3f inv0 = new org.joml.Matrix4f().set(t0).get3x3(new org.joml.Matrix3f()).invert();
|
|
float[] ty = captureTransform(r, 90, 0);
|
|
if (ty != null) {
|
|
org.joml.Matrix3f rel = new org.joml.Matrix4f().set(ty).get3x3(new org.joml.Matrix3f()).mul(inv0);
|
|
// rel ≈ RotY(±90): m20 (column 0, row 2) = -sin(angle)
|
|
float sinA = -rel.m02;
|
|
info.addProperty("yawSign", Math.abs(sinA) < 0.5f ? 0 : (sinA > 0 ? 1 : -1));
|
|
}
|
|
float[] tp = captureTransform(r, 0, 90);
|
|
if (tp != null) {
|
|
org.joml.Matrix3f rel = new org.joml.Matrix4f().set(tp).get3x3(new org.joml.Matrix3f()).mul(inv0);
|
|
// axis of a 90° rotation: the antisymmetric part (R - R^T)/2
|
|
float ax = (rel.m12 - rel.m21) / 2, ay = (rel.m20 - rel.m02) / 2, az = (rel.m01 - rel.m10) / 2;
|
|
float len = (float) Math.sqrt(ax * ax + ay * ay + az * az);
|
|
if (len > 0.5f) info.add("pitchAxis", arr(ax / len, ay / len, az / len));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* State-dependent poses as per-part overrides (only parts that differ from the idle pose): sitting as a
|
|
* passenger ({@code isPassenger}/{@code isRiding}), holding an item in the right/left hand
|
|
* ({@code right/leftArmPose = ITEM}) and their combinations, plus illager arm poses. Stored per entity
|
|
* type as {@code poses: {variant: {part: {pivot, rotate, visible}}}}; the baker applies them by part
|
|
* name to the body AND its layer models (armor follows the arms, like vanilla's copyPropertiesTo).
|
|
*/
|
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
|
static void addPoseVariants(JsonObject info, Model<?> model, EntityRenderer<?, ?> r) {
|
|
Object probe;
|
|
try {
|
|
probe = r.createRenderState();
|
|
} catch (Throwable t) {
|
|
return;
|
|
}
|
|
// Toggle groups: {name, field, value} entries sharing a name are applied together.
|
|
List<List<String[]>> toggles = new ArrayList<>();
|
|
List<String[]> passenger = new ArrayList<>();
|
|
for (String f : new String[] {"isPassenger", "isRiding"})
|
|
if (hasField(probe, f)) passenger.add(new String[] {"passenger", f, "true"});
|
|
if (!passenger.isEmpty()) toggles.add(passenger);
|
|
if (hasField(probe, "rightArmPose") && probeEnum(probe, "rightArmPose")) toggles.add(java.util.Collections.singletonList(new String[] {"item_r", "rightArmPose", "ITEM"}));
|
|
if (hasField(probe, "leftArmPose") && probeEnum(probe, "leftArmPose")) toggles.add(java.util.Collections.singletonList(new String[] {"item_l", "leftArmPose", "ITEM"}));
|
|
// State flags that switch model parts on in setupAnim: armor-stand arms, donkey/mule/llama chests.
|
|
for (String[] f : new String[][] {{"arms", "showArms"}, {"chest", "hasChest"}}) {
|
|
if (hasField(probe, f[1]))
|
|
toggles.add(java.util.Collections.singletonList(new String[] {f[0], f[1], "true"}));
|
|
}
|
|
List<List<String[]>> combos = new ArrayList<>();
|
|
for (int mask = 1; mask < (1 << toggles.size()); mask++) {
|
|
List<String[]> c = new ArrayList<>();
|
|
for (int i = 0; i < toggles.size(); i++) if ((mask & (1 << i)) != 0) c.addAll(toggles.get(i));
|
|
combos.add(c);
|
|
}
|
|
// Illagers: the arm pose enum replaces the crossed arms (crossbow hold, attacking, …).
|
|
if (hasField(probe, "armPose")) {
|
|
for (String v : new String[] {"CROSSBOW_HOLD", "ATTACKING", "NEUTRAL", "BOW_AND_ARROW"}) {
|
|
List<String[]> c = new ArrayList<>();
|
|
c.add(new String[] {"arms_" + v.toLowerCase(), "armPose", v});
|
|
combos.add(c);
|
|
if (!passenger.isEmpty()) {
|
|
List<String[]> cr = new ArrayList<>(c);
|
|
cr.addAll(passenger);
|
|
combos.add(cr);
|
|
}
|
|
}
|
|
}
|
|
if (combos.isEmpty()) return;
|
|
Map<String, float[]> base = poseOf(model, freshState(r, List.of()));
|
|
if (base == null) return;
|
|
JsonObject poses = new JsonObject();
|
|
for (List<String[]> c : combos) {
|
|
Map<String, float[]> v = poseOf(model, freshState(r, c));
|
|
if (v == null) continue;
|
|
JsonObject diff = new JsonObject();
|
|
for (Map.Entry<String, float[]> e : v.entrySet()) {
|
|
float[] b = base.get(e.getKey());
|
|
float[] x = e.getValue();
|
|
boolean same = b != null;
|
|
for (int i = 0; same && i < x.length; i++) same = Math.abs(b[i] - x[i]) < 1e-4f;
|
|
if (same) continue;
|
|
JsonObject po = new JsonObject();
|
|
boolean moved = b == null;
|
|
for (int i = 0; !moved && i < 6; i++) moved = Math.abs(b[i] - x[i]) >= 1e-4f;
|
|
if (moved) {
|
|
// Absolute pivot + rotation; omitted for pure visibility toggles (arms, chests), which then
|
|
// apply to every model of the type (small/baby models have other pivots).
|
|
po.add("pivot", arr(x[0], x[1], x[2]));
|
|
po.add("rotate", arr(x[3], x[4], x[5]));
|
|
}
|
|
po.addProperty("visible", x[6] > 0.5f);
|
|
diff.add(e.getKey(), po);
|
|
}
|
|
if (diff.isEmpty()) continue;
|
|
StringBuilder name = new StringBuilder();
|
|
c.stream().map(t -> t[0]).distinct().sorted().forEach(n -> name.append(name.isEmpty() ? "" : "+").append(n));
|
|
poses.add(name.toString(), diff);
|
|
}
|
|
if (!poses.isEmpty()) info.add("poses", poses);
|
|
// leave the model in its idle pose for the dump
|
|
poseOf(model, freshState(r, List.of()));
|
|
}
|
|
|
|
/** Whether the state field is an enum (HumanoidModel.ArmPose), not e.g. an armor stand's Rotations. */
|
|
static boolean probeEnum(Object o, String f) {
|
|
try {
|
|
return o.getClass().getField(f).getType().isEnum();
|
|
} catch (NoSuchFieldException e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static boolean hasField(Object o, String f) {
|
|
try {
|
|
o.getClass().getField(f);
|
|
return true;
|
|
} catch (NoSuchFieldException e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
|
static Object freshState(EntityRenderer<?, ?> r, List<String[]> sets) {
|
|
try {
|
|
Object st = r.createRenderState();
|
|
idleTweaks(st);
|
|
for (String[] t : sets) {
|
|
Field f = st.getClass().getField(t[1]);
|
|
if (f.getType() == boolean.class) f.setBoolean(st, Boolean.parseBoolean(t[2]));
|
|
else if (f.getType().isEnum()) f.set(st, Enum.valueOf((Class) f.getType(), t[2]));
|
|
}
|
|
return st;
|
|
} catch (Throwable t) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** setupAnim with the state, then every part's absolute CEM pivot, CEM rotation and visibility, by name. */
|
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
|
static Map<String, float[]> poseOf(Model<?> model, Object state) {
|
|
if (state == null) return null;
|
|
try {
|
|
((Model) model).setupAnim(state);
|
|
} catch (Throwable t) {
|
|
return null;
|
|
}
|
|
Map<String, float[]> out = new LinkedHashMap<>();
|
|
collectPose(model.root(), "root", 0, 0, 0, out);
|
|
return out;
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
static void collectPose(ModelPart p, String name, float px, float py, float pz, Map<String, float[]> out) {
|
|
float ax = px + p.x, ay = py + p.y, az = pz + p.z;
|
|
out.put(name, new float[] {
|
|
-ax, 24 - ay, az,
|
|
(float) Math.toDegrees(-p.xRot), (float) Math.toDegrees(-p.yRot), (float) Math.toDegrees(p.zRot),
|
|
p.visible ? 1 : 0
|
|
});
|
|
Map<String, ModelPart> children = (Map<String, ModelPart>) fieldValue(p, "children");
|
|
if (children != null) children.forEach((n, c) -> collectPose(c, n, ax, ay, az, out));
|
|
}
|
|
|
|
static void setFloat(Object o, String field, float v) {
|
|
try {
|
|
Field f = o.getClass().getField(field);
|
|
if (f.getType() == float.class) f.setFloat(o, v);
|
|
} catch (Throwable ignored) {
|
|
}
|
|
}
|
|
|
|
static float[] captureTransform(EntityRenderer<?, ?> r, float yaw, float pitch) {
|
|
Object state;
|
|
try {
|
|
state = r.createRenderState();
|
|
} catch (Throwable t) {
|
|
return null;
|
|
}
|
|
idleTweaks(state);
|
|
setFloat(state, "bodyRot", yaw);
|
|
setFloat(state, "yRot", yaw);
|
|
setFloat(state, "xRot", pitch);
|
|
float[][] first = new float[1][];
|
|
Class<?> collector;
|
|
try {
|
|
collector = Class.forName("net.minecraft.client.renderer.SubmitNodeCollector");
|
|
} catch (ClassNotFoundException e) {
|
|
return null;
|
|
}
|
|
Object proxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
VanillaModelDump.class.getClassLoader(), new Class<?>[] {collector}, (px, m, a) -> {
|
|
if (m.getName().equals("order")) return px;
|
|
if (a != null && first[0] == null && m.getName().startsWith("submitModel")) {
|
|
for (Object arg : a) {
|
|
if (arg instanceof com.mojang.blaze3d.vertex.PoseStack ps) {
|
|
first[0] = ps.last().pose().get(new float[16]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Class<?> rt = m.getReturnType();
|
|
if (rt == boolean.class) return false;
|
|
if (rt.isPrimitive() && rt != void.class) return 0;
|
|
return null;
|
|
});
|
|
try {
|
|
Method submit = null;
|
|
for (Method m : r.getClass().getMethods()) {
|
|
if (m.getName().equals("submit") && m.getParameterCount() == 4 && !m.isBridge()) submit = m;
|
|
}
|
|
if (submit == null) return null;
|
|
submit.invoke(r, state, new com.mojang.blaze3d.vertex.PoseStack(), proxy, null);
|
|
} catch (Throwable ignored) {
|
|
// partial submissions still count (layers may fail on the null context)
|
|
}
|
|
return first[0];
|
|
}
|
|
|
|
/** Render scale (the renderer's own {@code scale(state, poseStack)}) and default adult/baby textures. */
|
|
static JsonObject entityInfo(EntityRenderer<?, ?> r, Object state) {
|
|
JsonObject o = new JsonObject();
|
|
if (state == null) return o;
|
|
try {
|
|
Method m = null;
|
|
for (Class<?> c = r.getClass(); c != null && m == null; c = c.getSuperclass()) {
|
|
for (Method dm : c.getDeclaredMethods()) {
|
|
if (dm.getName().equals("scale")
|
|
&& dm.getParameterCount() == 2
|
|
&& dm.getParameterTypes()[1].getSimpleName().equals("PoseStack")
|
|
&& !dm.isBridge()) {
|
|
m = dm;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (m != null) {
|
|
m.setAccessible(true);
|
|
com.mojang.blaze3d.vertex.PoseStack ps = new com.mojang.blaze3d.vertex.PoseStack();
|
|
m.invoke(r, state, ps);
|
|
float sc = ps.last().pose().getScale(new org.joml.Vector3f()).y;
|
|
if (sc > 0.05f && Math.abs(sc - 1) > 1e-3) o.addProperty("scale", Math.round(sc * 1000f) / 1000f);
|
|
}
|
|
} catch (Throwable ignored) {
|
|
}
|
|
for (boolean baby : new boolean[] {false, true}) {
|
|
try {
|
|
Object st = r.createRenderState();
|
|
try {
|
|
st.getClass().getField("isBaby").setBoolean(st, baby);
|
|
} catch (NoSuchFieldException e) {
|
|
if (baby) continue;
|
|
}
|
|
Method gt = null;
|
|
for (Class<?> c = r.getClass(); c != null && gt == null; c = c.getSuperclass()) {
|
|
for (Method dm : c.getDeclaredMethods())
|
|
if (dm.getName().equals("getTextureLocation") && dm.getParameterCount() == 1 && !dm.isBridge())
|
|
gt = dm;
|
|
}
|
|
if (gt == null) break;
|
|
gt.setAccessible(true);
|
|
Object id = gt.invoke(r, st);
|
|
if (id != null) {
|
|
String path = id.toString().replaceFirst("^minecraft:", "")
|
|
.replaceFirst("^textures/", "")
|
|
.replaceFirst("\\.png$", "");
|
|
o.addProperty(baby ? "babyTexture" : "texture", path);
|
|
}
|
|
} catch (Throwable ignored) {
|
|
}
|
|
}
|
|
return o;
|
|
}
|
|
|
|
/** Finds Model instances in the object's fields (also inside records / maps / ArmorModelSet-like holders). */
|
|
static void collectModels(Object o, String prefix, Map<String, Model<?>> found, int depth) {
|
|
if (o == null || depth > 2) return;
|
|
for (Class<?> c = o.getClass(); c != null && c != Object.class; c = c.getSuperclass()) {
|
|
for (Field f : c.getDeclaredFields()) {
|
|
if (Modifier.isStatic(f.getModifiers())) continue;
|
|
Object v;
|
|
try {
|
|
f.setAccessible(true);
|
|
v = f.get(o);
|
|
} catch (Throwable t) {
|
|
continue;
|
|
}
|
|
if (v == null) continue;
|
|
String n = prefix + f.getName();
|
|
if (v instanceof Model<?> m) {
|
|
if (!found.containsValue(m)) found.put(n, m);
|
|
} else if (v instanceof Map<?, ?> map) {
|
|
for (Map.Entry<?, ?> me : map.entrySet()) {
|
|
String mk = n + "." + String.valueOf(me.getKey()).toLowerCase();
|
|
if (me.getValue() instanceof Model<?> m) {
|
|
if (!found.containsValue(m)) found.put(mk, m);
|
|
} else if (me.getValue() != null) {
|
|
collectModels(me.getValue(), mk + ".", found, depth + 1); // e.g. adult/baby pairs
|
|
}
|
|
}
|
|
} else if (v.getClass().isRecord() || v.getClass().getSimpleName().endsWith("ModelSet")) {
|
|
collectModels(v, n + ".", found, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Path of child names from {@code from} down to the identical part {@code target}, or null. */
|
|
@SuppressWarnings("unchecked")
|
|
static List<String> findPath(ModelPart from, ModelPart target, List<String> path) {
|
|
if (from == target) return path;
|
|
Map<String, ModelPart> children = (Map<String, ModelPart>) fieldValue(from, "children");
|
|
if (children == null) return null;
|
|
for (Map.Entry<String, ModelPart> c : children.entrySet()) {
|
|
List<String> p = new ArrayList<>(path);
|
|
p.add(c.getKey());
|
|
List<String> r = findPath(c.getValue(), target, p);
|
|
if (r != null) return r;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static Object fieldValue(Object o, String name) {
|
|
for (Class<?> c = o.getClass(); c != null; c = c.getSuperclass()) {
|
|
try {
|
|
Field f = c.getDeclaredField(name);
|
|
f.setAccessible(true);
|
|
return f.get(o);
|
|
} catch (NoSuchFieldException ignored) {
|
|
} catch (Throwable t) {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
|
static JsonObject dump(Model<?> model, Object state, String name, List<String> failed) {
|
|
ModelPart root = model.root();
|
|
ModelLayerLocation loc = BAKED.get(root);
|
|
List<String> subPath = List.of();
|
|
if (loc == null) {
|
|
// Some models keep a CHILD of the baked layer root as their root (e.g. allay, vex, frog).
|
|
for (Map.Entry<Object, ModelLayerLocation> b : BAKED.entrySet()) {
|
|
List<String> path = findPath((ModelPart) b.getKey(), root, new ArrayList<>());
|
|
if (path != null) {
|
|
loc = b.getValue();
|
|
subPath = path;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (loc == null) {
|
|
failed.add(name + " (no layer)");
|
|
return null;
|
|
}
|
|
// Pose with the renderer's default state; fall back to a fresh state of the model's own type.
|
|
boolean posed = false;
|
|
if (state != null) {
|
|
try {
|
|
((Model) model).setupAnim(state);
|
|
posed = true;
|
|
} catch (Throwable t) {
|
|
// layer model expecting another state type
|
|
}
|
|
}
|
|
if (!posed) {
|
|
for (Method m : model.getClass().getMethods()) {
|
|
if (!m.getName().equals("setupAnim") || m.getParameterCount() != 1) continue;
|
|
try {
|
|
Object st = m.getParameterTypes()[0].getDeclaredConstructor().newInstance();
|
|
m.invoke(model, st);
|
|
posed = true;
|
|
break;
|
|
} catch (Throwable ignored) {
|
|
}
|
|
}
|
|
}
|
|
if (!posed) failed.add(name + " (rest pose: setupAnim failed)");
|
|
|
|
LayerDefinition def = roots.get(loc);
|
|
PartDefinition rootDef = ((net.minecraft.client.model.geom.builders.MeshDefinition)
|
|
fieldValue(def, "mesh"))
|
|
.getRoot();
|
|
for (String seg : subPath) rootDef = rootDef.getChild(seg);
|
|
Object mat = fieldValue(def, "material");
|
|
int tw = (int) fieldValue(mat, "xTexSize"), th = (int) fieldValue(mat, "yTexSize");
|
|
|
|
JsonObject jem = new JsonObject();
|
|
JsonArray ts = new JsonArray();
|
|
ts.add(tw);
|
|
ts.add(th);
|
|
jem.add("textureSize", ts);
|
|
jem.addProperty("absolute", true);
|
|
jem.addProperty("layer", loc.model().getPath() + "#" + loc.layer());
|
|
JsonArray parts = new JsonArray();
|
|
parts.add(part(subPath.isEmpty() ? "root" : subPath.getLast(), rootDef, root, 0, 0, 0));
|
|
jem.add("models", parts);
|
|
return jem;
|
|
}
|
|
|
|
/** One part: absolute pivot = parent pivot + this part's (posed) offset; boxes absolute. */
|
|
@SuppressWarnings("unchecked")
|
|
static JsonObject part(String name, PartDefinition def, ModelPart p, float px, float py, float pz) {
|
|
float ax = px + p.x, ay = py + p.y, az = pz + p.z;
|
|
JsonObject o = new JsonObject();
|
|
o.addProperty("part", name);
|
|
o.add("pivot", arr(-ax, 24 - ay, az));
|
|
// CEM frame = Java rotated 180° about Z: x/y rotations flip sign, z keeps it.
|
|
o.add("rotate", arr(
|
|
(float) Math.toDegrees(-p.xRot), (float) Math.toDegrees(-p.yRot), (float) Math.toDegrees(p.zRot)));
|
|
if (p.xScale != 1 || p.yScale != 1 || p.zScale != 1) o.add("scale", arr(p.xScale, p.yScale, p.zScale));
|
|
if (!p.visible) o.addProperty("invisible", true);
|
|
if (p.skipDraw) o.addProperty("skipDraw", true);
|
|
JsonArray boxes = new JsonArray();
|
|
for (CubeDefinition c : (List<CubeDefinition>) fieldValue(def, "cubes")) {
|
|
org.joml.Vector3fc org = (org.joml.Vector3fc) fieldValue(c, "origin");
|
|
org.joml.Vector3fc dim = (org.joml.Vector3fc) fieldValue(c, "dimensions");
|
|
Object grow = fieldValue(c, "grow");
|
|
float gx = (float) fieldValue(grow, "growX"), gy = (float) fieldValue(grow, "growY"),
|
|
gz = (float) fieldValue(grow, "growZ");
|
|
Object tex = fieldValue(c, "texCoord"), texScale = fieldValue(c, "texScale");
|
|
boolean mirror = (boolean) fieldValue(c, "mirror");
|
|
Set<Direction> faces = (Set<Direction>) fieldValue(c, "visibleFaces");
|
|
float x0 = ax + org.x(), y0 = ay + org.y(), z0 = az + org.z();
|
|
JsonObject b = new JsonObject();
|
|
b.add("coordinates", arr(-(x0 + dim.x()), 24 - (y0 + dim.y()), z0, dim.x(), dim.y(), dim.z()));
|
|
b.add("textureOffset", arr((float) fieldValue(tex, "u"), (float) fieldValue(tex, "v")));
|
|
float su = (float) fieldValue(texScale, "u"), sv = (float) fieldValue(texScale, "v");
|
|
if (su != 1 || sv != 1) b.add("textureScale", arr(su, sv));
|
|
if (gx == gy && gy == gz) {
|
|
if (gx != 0) b.addProperty("sizeAdd", gx);
|
|
} else {
|
|
b.add("sizeAddXYZ", arr(gx, gy, gz));
|
|
}
|
|
if (mirror) b.addProperty("mirrorTexture", "u");
|
|
if (faces != null && faces.size() < 6) {
|
|
JsonArray hide = new JsonArray();
|
|
// Named in the CEM frame (x and y flipped): Java east is CEM west, Java up is CEM down.
|
|
for (Direction d : Direction.values())
|
|
if (!faces.contains(d))
|
|
hide.add(
|
|
switch (d) {
|
|
case EAST -> "west";
|
|
case WEST -> "east";
|
|
case UP -> "down";
|
|
case DOWN -> "up";
|
|
default -> d.getName();
|
|
});
|
|
b.add("hiddenFaces", hide);
|
|
}
|
|
boxes.add(b);
|
|
}
|
|
if (!boxes.isEmpty()) o.add("boxes", boxes);
|
|
JsonArray subs = new JsonArray();
|
|
for (Map.Entry<String, PartDefinition> ce : def.getChildren()) {
|
|
ModelPart child = p.getChild(ce.getKey());
|
|
subs.add(part(ce.getKey(), ce.getValue(), child, ax, ay, az));
|
|
}
|
|
if (!subs.isEmpty()) o.add("submodels", subs);
|
|
return o;
|
|
}
|
|
|
|
static JsonArray arr(float... v) {
|
|
JsonArray a = new JsonArray();
|
|
for (float f : v) a.add(Math.round(f * 10000f) / 10000f);
|
|
return a;
|
|
}
|
|
}
|