Files
PixelPics/tools/VanillaModelDump.java
T

1259 lines
63 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>}}}}}; the jem is a
* nested object, not the template set's embedded JSON string, so the plugin parses the file only once).
*
* <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);
addPoseVariants(info, found, key, r);
addRotationBindings(info, found, 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);
addPoseVariants(info, found, key, r);
addRotationBindings(info, found, 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.add("model", jem); // nested object, not a JSON string: parsed once at plugin start
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.add("model", 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.add("model", 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));
}
}
/**
* Named STATE poses (the render-state fields vanilla's extractRenderState fills from the entity's pose/flags):
* {@code {pose, field, value[, "unless:<field>"]}}. A pose applies to a renderer when at least one of its
* assignments finds its field; the others are skipped (one name covers fox/cat/wolf/panda/parrot/camel
* sitting, …). Values: boolean, float, enum constant name, or {@code anim:<ticks>} = start that
* AnimationState that many ticks before the frame (static keyframe pose). The snapshot maps the entity's
* Bukkit state onto these names; the dump only keeps poses that change parts or the renderer transform.
*/
static final String[][] STATE_POSES = {
{"sleeping", "isSleeping", "true"},
{"sleeping", "pose", "SLEEPING", "unless:isSleeping"}, // LivingEntityRenderer: lying in bed
{"sitting", "isSitting", "true"},
{"sitting", "sitAmount", "1"}, // panda
{"sitting", "pose", "SITTING"}, // parrot (ParrotModel.Pose), camel
{"sitting", "sitPoseAnimationState", "anim:0"}, // camel
{"crouching", "isCrouching", "true"},
{"crouching", "crouchAmount", "3"}, // fox (Fox.getCrouchAmount max)
{"crouching", "pose", "CROUCHING"},
{"crouching", "elytraRotX", "0.6981317"}, // ElytraAnimationState crouching targets
{"crouching", "elytraRotY", "0.08726646"},
{"crouching", "elytraRotZ", "-0.7853982"},
{"pouncing", "isPouncing", "true"}, // fox leap
{"faceplanted", "isFaceplanted", "true"}, // fox stuck in snow
{"lying", "lieDownAmount", "1"}, // cat
{"lying", "lieDownAmountTail", "1"},
{"head_up", "relaxStateOneAmount", "1"}, // cat
{"on_player", "isLyingOnTopOfSleepingPlayer", "true"}, // cat on its sleeping owner
{"on_back", "lieOnBackAmount", "1"}, // panda
{"resting", "isResting", "true"}, // bat hanging
{"resting", "restAnimationState", "anim:0"},
{"swimming", "isVisuallySwimming", "true"},
{"swimming", "swimAmount", "1"},
{"swimming", "isInWater", "true"},
{"swimming", "pose", "SWIMMING"},
{"crawling", "isVisuallySwimming", "true"}, // swimming pose out of water
{"crawling", "swimAmount", "1"},
{"crawling", "pose", "SWIMMING"},
{"fall_flying", "isFallFlying", "true"},
{"fall_flying", "fallFlyingTimeInTicks", "20"},
{"fall_flying", "pose", "FALL_FLYING"},
{"fall_flying", "elytraRotX", "0.34906584"}, // level glide
{"fall_flying", "elytraRotZ", "-1.5707964"},
{"in_water", "isInWater", "true"}, // fish lie on their side only out of water
{"standing", "pose", "STANDING"}, // parrot on the ground (the render-state default is FLYING)
{"on_shoulder", "pose", "ON_SHOULDER"}, // parrot on a player's shoulder (ParrotOnShoulderLayer)
{"party", "pose", "PARTY"}, // dancing parrot
{"digging", "pose", "DIGGING"},
{"digging", "diggingAnimationState", "anim:72"}, // sniffer: head and body down in the ground
};
/** State poses that combine (every subset of a group with 2+ applicable members is dumped too). */
static final List<List<String>> POSE_GROUPS =
List.of(List.of("crouching", "pouncing"), List.of("head_up", "lying", "on_player"));
/**
* 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, illager arm poses, and the {@link #STATE_POSES}
* (alone, in {@link #POSE_GROUPS} combinations and with held items). Stored per entity type as
* {@code poses: {variant: {part: {pivot, rotate, visible[, scale]}}}} for the main body model and
* {@code modelPoses: {<sub-model>: {poses, poseTransforms}}} for the other body models (baby, small, …: own
* pivots); the baker applies them by part name to the body AND its layer models (armor follows the arms,
* like vanilla's copyPropertiesTo). State poses also carry the parts of layer models the body lacks (elytra
* wings) and, when the renderer transform changes (sleeping, swimming, fish in water, …),
* {@code poseTransforms: {variant: {transform, yawSign[, pitchAxis]}}} measured like {@link #addTransform}.
*/
static void addPoseVariants(
JsonObject info, Map<String, Model<?>> found, String key, 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);
List<List<String[]>> items = new ArrayList<>();
if (hasField(probe, "rightArmPose") && probeEnum(probe, "rightArmPose"))
items.add(java.util.Collections.singletonList(new String[] {"item_r", "rightArmPose", "ITEM"}));
if (hasField(probe, "leftArmPose") && probeEnum(probe, "leftArmPose"))
items.add(java.util.Collections.singletonList(new String[] {"item_l", "leftArmPose", "ITEM"}));
toggles.addAll(items);
// 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);
}
}
}
// State poses: alone, grouped, each with every held-item subset.
List<List<String[]>> stateCombos = new ArrayList<>();
java.util.Set<String> applicable = new java.util.LinkedHashSet<>();
for (String[] a : STATE_POSES) if (assignmentApplies(probe, a)) applicable.add(a[0]);
List<List<String>> stateNames = new ArrayList<>();
for (String n : applicable) stateNames.add(List.of(n));
for (List<String> g : POSE_GROUPS) {
List<String> members = g.stream().filter(applicable::contains).toList();
for (int mask = 1; mask < (1 << members.size()); mask++) {
if (Integer.bitCount(mask) < 2) continue;
List<String> sub = new ArrayList<>();
for (int i = 0; i < members.size(); i++) if ((mask & (1 << i)) != 0) sub.add(members.get(i));
stateNames.add(sub);
}
}
for (List<String> names : stateNames) {
List<String[]> base = new ArrayList<>();
for (String n : names) for (String[] a : STATE_POSES) if (a[0].equals(n)) base.add(a);
for (int mask = 0; mask < (1 << items.size()); mask++) {
List<String[]> c = new ArrayList<>(base);
for (int i = 0; i < items.size(); i++) if ((mask & (1 << i)) != 0) c.addAll(items.get(i));
stateCombos.add(c);
}
}
if (combos.isEmpty() && stateCombos.isEmpty()) return;
// Every body model (main, baby, small, variant models) gets its own overrides: pivots differ per model.
float[][] t0 = transformTriple(r, List.of(), false);
for (Map.Entry<String, Model<?>> bm : found.entrySet()) {
String f = bm.getKey();
if (f.contains("Layer.") || f.startsWith("equip.")) continue;
boolean main = f.equals("model") || f.equals("adultModel") || f.equals("normalModel");
if (main && bm.getValue() != (found.containsKey("model") ? found.get("model") : found.get("adultModel")))
continue;
boolean baby = f.toLowerCase().contains("baby");
List<Model<?>> layerModels = new ArrayList<>();
for (Map.Entry<String, Model<?>> lm : found.entrySet()) {
String ln = lm.getKey();
if (!ln.contains("Layer.") || ln.toLowerCase().contains("baby") != baby) continue;
layerModels.add(lm.getValue());
}
JsonObject poses = new JsonObject();
JsonObject transforms = new JsonObject();
Model<?> model = bm.getValue();
// Main model: the dumped idle state (unchanged poses). Baby models: a baby's state (isBaby, ageScale
// 0.5 — e.g. the wolf's sitting offsets scale with it).
boolean babyState = baby && !main;
Map<String, float[]> base = poseOf(model, freshState(r, List.of(), babyState));
if (base == null) continue;
for (List<String[]> c : combos) {
if (!c.stream().allMatch(a -> assignmentApplies(probe, a))) continue; // e.g. piglin arm poses
addPose(poses, c, model, base, poseOf(model, freshState(r, c, babyState)), false);
}
Map<String, float[]> baseAll = withLayers(base, layerModels, freshState(r, List.of(), babyState));
java.util.Set<String> emitted = new java.util.HashSet<>();
for (List<String[]> c : stateCombos) {
// Held-item variants only for state poses that exist on their own (no item_r+standing duplicates).
String states = c.stream().filter(a -> !a[0].startsWith("item_")).map(a -> a[0]).distinct().sorted()
.reduce((x, y) -> x + "+" + y).orElse("");
boolean withItems = c.stream().anyMatch(a -> a[0].startsWith("item_"));
if (withItems && !emitted.contains(states)) continue;
Map<String, float[]> v = withLayers(
poseOf(model, freshState(r, c, babyState)), layerModels, freshState(r, c, babyState));
float[][] tr = transformTriple(r, c, baby);
boolean newTransform = tr != null && t0 != null && !sameTriple(tr, t0);
String name = addPose(poses, c, model, baseAll, v, newTransform);
if (name != null) emitted.add(name);
if (name != null && newTransform) transforms.add(name, tripleJson(tr));
}
poseOf(model, freshState(r, List.of())); // leave the model in its idle pose for the dump
if (main) {
if (!poses.isEmpty()) info.add("poses", poses);
if (!transforms.isEmpty()) info.add("poseTransforms", transforms);
} else {
// Always present (also when empty): the baker then never falls back to the main model's pivots.
String sub = f.equals("babyModel") ? "baby" : f;
if (!info.has("modelPoses")) info.add("modelPoses", new JsonObject());
JsonObject mp = new JsonObject();
mp.add("poses", poses);
if (!transforms.isEmpty()) mp.add("poseTransforms", transforms);
info.getAsJsonObject("modelPoses").add(sub, mp);
}
}
for (Model<?> lm : found.values()) poseOf(lm, freshState(r, List.of()));
}
/**
* Adds the pose {@code v} of combo {@code c} as a diff against {@code base}; returns its name, or null when
* nothing differs (and {@code keepEmpty} is false — state poses that only move the renderer transform).
*/
static String addPose(
JsonObject poses,
List<String[]> c,
Model<?> model,
Map<String, float[]> base,
Map<String, float[]> v,
boolean keepEmpty) {
if (v == null) return null;
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);
boolean scaled = b == null ? (x[7] != 1 || x[8] != 1 || x[9] != 1) : false;
for (int i = 7; b != null && i < 10; i++) scaled |= Math.abs(b[i] - x[i]) >= 1e-4f;
if (scaled) po.add("scale", arr(x[7], x[8], x[9]));
diff.add(e.getKey(), po);
}
if (diff.isEmpty() && !keepEmpty) return null;
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);
return name.toString();
}
/** {@code parts} plus the posed parts of layer models that the body model lacks (elytra wings, …). */
static Map<String, float[]> withLayers(Map<String, float[]> parts, List<Model<?>> layers, Object state) {
if (parts == null) return null;
Map<String, float[]> out = new LinkedHashMap<>(parts);
for (Model<?> lm : layers) {
Map<String, float[]> lp = poseOf(lm, state);
if (lp == null) continue;
lp.remove("root");
lp.forEach(out::putIfAbsent);
}
return out;
}
/** Renderer transform, yaw sign and pitch axis for a posed state (see {@link #addTransform}); null = none. */
static float[][] transformTriple(EntityRenderer<?, ?> r, List<String[]> assigns, boolean baby) {
float[] t = captureTransform(r, 0, 0, assigns, baby);
if (t == null) return null;
org.joml.Matrix3f inv0 = new org.joml.Matrix4f().set(t).get3x3(new org.joml.Matrix3f()).invert();
float[] yaw = {0};
float[] ty = captureTransform(r, 90, 0, assigns, baby);
if (ty != null) {
org.joml.Matrix3f rel = new org.joml.Matrix4f().set(ty).get3x3(new org.joml.Matrix3f()).mul(inv0);
float sinA = -rel.m02;
yaw[0] = Math.abs(sinA) < 0.5f ? 0 : (sinA > 0 ? 1 : -1);
}
float[] axis = null;
float[] tp = captureTransform(r, 0, 90, assigns, baby);
if (tp != null) {
org.joml.Matrix3f rel = new org.joml.Matrix4f().set(tp).get3x3(new org.joml.Matrix3f()).mul(inv0);
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) axis = new float[] {ax / len, ay / len, az / len};
}
return new float[][] {t, yaw, axis};
}
static boolean sameTriple(float[][] a, float[][] b) {
for (int i = 0; i < 16; i++) if (Math.abs(a[0][i] - b[0][i]) > 1e-4f) return false;
if (a[1][0] != b[1][0]) return false;
if ((a[2] == null) != (b[2] == null)) return false;
if (a[2] != null) for (int i = 0; i < 3; i++) if (Math.abs(a[2][i] - b[2][i]) > 1e-3f) return false;
return true;
}
static JsonObject tripleJson(float[][] tr) {
JsonObject o = new JsonObject();
JsonArray ta = new JsonArray();
for (float v : tr[0]) ta.add(Math.round(v * 100000f) / 100000f);
o.add("transform", ta);
o.addProperty("yawSign", (int) tr[1][0]);
if (tr[2] != null) o.add("pitchAxis", arr(tr[2][0], tr[2][1], tr[2][2]));
return o;
}
/**
* Continuous state inputs bound linearly to part rotations: every {@code Rotations} field (armor-stand
* head/body/arm/leg poses) and {@code xRot} (entity pitch: dolphin body, heads, …), measured by
* re-running setupAnim with each component +10°. Stored as {@code rotationBindings: {field: {default:
* [x,y,z], parts: {part: [9 coefficients, row = CEM rotate axis, column = field component]}}}}; the baker
* adds {@code coeff · (value − default)} to each named part's rotation (body and layer models alike).
*/
@SuppressWarnings({"unchecked", "rawtypes"})
static void addRotationBindings(JsonObject info, Map<String, Model<?>> found, EntityRenderer<?, ?> r) {
Object probe;
try {
probe = r.createRenderState();
} catch (Throwable t) {
return;
}
Model<?> main = found.containsKey("model") ? found.get("model") : found.get("adultModel");
if (main == null) return;
List<Model<?>> layers = new ArrayList<>();
for (Map.Entry<String, Model<?>> lm : found.entrySet())
if (lm.getKey().contains("Layer.") && !lm.getKey().toLowerCase().contains("baby")) layers.add(lm.getValue());
JsonObject out = new JsonObject();
for (Field f : probe.getClass().getFields()) {
if (Modifier.isStatic(f.getModifiers())) continue;
boolean rot = f.getType().getSimpleName().equals("Rotations");
if (!rot && !(f.getName().equals("xRot") && f.getType() == float.class)) continue;
try {
Object st0 = freshState(r, List.of());
Map<String, float[]> base = withLayers(poseOf(main, st0), layers, freshState(r, List.of()));
if (base == null) continue;
float[] def = new float[3];
if (rot) {
Object v = f.get(st0);
for (int j = 0; j < 3; j++) def[j] = (float) v.getClass().getMethod(new String[] {"x", "y", "z"}[j]).invoke(v);
} else {
def[0] = f.getFloat(st0);
}
Map<String, float[]> coeff = new TreeMap<>();
for (int j = 0; j < (rot ? 3 : 1); j++) {
Object st = freshState(r, List.of());
if (rot) {
float[] d = def.clone();
d[j] += 10;
f.set(st, f.getType().getConstructor(float.class, float.class, float.class).newInstance(d[0], d[1], d[2]));
} else {
f.setFloat(st, def[0] + 10);
}
Map<String, float[]> v = withLayers(poseOf(main, st), layers, st);
if (v == null) continue;
for (Map.Entry<String, float[]> e : v.entrySet()) {
float[] b = base.get(e.getKey());
if (b == null) continue;
for (int i = 0; i < 3; i++) {
float c = (e.getValue()[3 + i] - b[3 + i]) / 10f;
if (Math.abs(c) < 1e-4f) continue;
coeff.computeIfAbsent(e.getKey(), k -> new float[9])[i * 3 + j] = c;
}
}
}
// Pitch tilts whole bodies only (dolphin, strider, nautilus): head look is left out, PixelPics has
// no head yaw to go with it.
if (!rot) {
java.util.Set<String> heads = new java.util.HashSet<>();
headParts(main.root(), "root", false, heads);
for (Model<?> lm : layers) headParts(lm.root(), "root", false, heads);
coeff.keySet().removeAll(heads);
}
if (coeff.isEmpty()) continue;
JsonObject fb = new JsonObject();
fb.add("default", rot ? arr(def) : arr(def[0]));
JsonObject parts = new JsonObject();
coeff.forEach((p, c) -> parts.add(p, arr(c)));
fb.add("parts", parts);
out.add(f.getName(), fb);
} catch (Throwable t) {
System.out.println("rotation binding " + f.getName() + " (" + r.getClass().getSimpleName() + "): " + t);
}
}
poseOf(main, freshState(r, List.of()));
for (Model<?> lm : layers) poseOf(lm, freshState(r, List.of()));
if (!out.isEmpty()) info.add("rotationBindings", out);
}
/** Names of every part named "*head*" and of everything below one. */
@SuppressWarnings("unchecked")
static void headParts(ModelPart p, String name, boolean inHead, java.util.Set<String> out) {
inHead |= name.contains("head");
if (inHead) out.add(name);
Map<String, ModelPart> children = (Map<String, ModelPart>) fieldValue(p, "children");
if (children == null) return;
for (Map.Entry<String, ModelPart> c : children.entrySet()) headParts(c.getValue(), c.getKey(), inHead, out);
}
/** Whether a {@link #STATE_POSES} assignment finds its field (and an enum value its constant). */
static boolean assignmentApplies(Object st, String[] a) {
if (a.length > 3 && a[3].startsWith("unless:") && hasField(st, a[3].substring(7))) return false;
Field f;
try {
f = st.getClass().getField(a[1]);
} catch (NoSuchFieldException e) {
return false;
}
if (a[2].startsWith("anim:")) return f.getType().getSimpleName().equals("AnimationState");
if (f.getType().isEnum()) {
for (Object c : f.getType().getEnumConstants()) if (((Enum<?>) c).name().equals(a[2])) return true;
return false;
}
return f.getType() == boolean.class || f.getType() == float.class || f.getType() == int.class;
}
/** 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;
}
}
static Object freshState(EntityRenderer<?, ?> r, List<String[]> sets) {
return freshState(r, sets, false);
}
/** {@code baby}: also isBaby + ageScale 0.5 like a baby's extracted state (baby models' pose offsets). */
static Object freshState(EntityRenderer<?, ?> r, List<String[]> sets, boolean baby) {
try {
Object st = r.createRenderState();
idleTweaks(st);
for (String[] t : sets) assign(st, t);
if (baby) babyState(st);
return st;
} catch (Throwable t) {
return null;
}
}
static void babyState(Object st) throws ReflectiveOperationException {
if (hasField(st, "isBaby")) st.getClass().getField("isBaby").setBoolean(st, true);
if (hasField(st, "ageScale")) st.getClass().getField("ageScale").setFloat(st, 0.5f);
}
/** Applies one {@code {name, field, value[, cond]}} assignment when it applies (see {@link #STATE_POSES}). */
@SuppressWarnings({"unchecked", "rawtypes"})
static void assign(Object st, String[] t) throws ReflectiveOperationException {
if (!assignmentApplies(st, t)) return;
Field f = st.getClass().getField(t[1]);
if (t[2].startsWith("anim:")) {
f.getType().getMethod("start", int.class).invoke(f.get(st), -Integer.parseInt(t[2].substring(5)));
} else if (f.getType() == boolean.class) f.setBoolean(st, Boolean.parseBoolean(t[2]));
else if (f.getType() == float.class) f.setFloat(st, Float.parseFloat(t[2]));
else if (f.getType() == int.class) f.setInt(st, Integer.parseInt(t[2]));
else if (f.getType().isEnum()) f.set(st, Enum.valueOf((Class) f.getType(), t[2]));
}
/** 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, p.xScale, p.yScale, p.zScale
});
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) {
return captureTransform(r, yaw, pitch, null, false);
}
/**
* {@code assigns != null}: a posed state ({@link #STATE_POSES}, {@code isBaby} when {@code baby}); the
* renderer's {@code getRenderOffset} (crouching players sink 2 px) is then included like the dispatcher does.
*/
@SuppressWarnings({"unchecked", "rawtypes"})
static float[] captureTransform(
EntityRenderer<?, ?> r, float yaw, float pitch, List<String[]> assigns, boolean baby) {
Object state;
try {
state = r.createRenderState();
idleTweaks(state);
if (assigns != null) {
for (String[] a : assigns) assign(state, a);
if (baby) babyState(state);
}
} catch (Throwable t) {
return null;
}
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)
}
if (assigns != null && first[0] != null) {
try {
net.minecraft.world.phys.Vec3 off = ((EntityRenderer) r).getRenderOffset((net.minecraft.client.renderer.entity.state.EntityRenderState) state);
if (off != null && off.lengthSqr() > 0) {
first[0] = new org.joml.Matrix4f().set(first[0])
.translateLocal((float) off.x, (float) off.y, (float) off.z)
.get(new float[16]);
}
} catch (Throwable ignored) {
// no render offset for this state
}
}
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;
}
}