Files
PixelPics/tools/VanillaModelDump.java
T

1505 lines
74 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 com.google.gson.JsonParser;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.renderpearl.api.pipeline.BlendFunction;
import com.mojang.renderpearl.api.pipeline.ColorTargetState;
import com.mojang.renderpearl.api.pipeline.RenderPipeline;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
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.ModelLayers;
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.MeshDefinition;
import net.minecraft.client.model.geom.builders.PartDefinition;
import net.minecraft.client.model.object.banner.BannerFlagModel;
import net.minecraft.client.model.object.book.BookModel;
import net.minecraft.client.model.object.skull.DragonHeadModel;
import net.minecraft.client.model.object.skull.PiglinHeadModel;
import net.minecraft.client.model.object.skull.SkullModelBase;
import net.minecraft.client.model.object.statue.CopperGolemStatueModel;
import net.minecraft.client.renderer.ShaderDefines;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.entity.layers.HumanoidArmorLayer;
import net.minecraft.client.renderer.entity.player.AvatarRenderer;
import net.minecraft.client.renderer.entity.state.EntityRenderState;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.Bootstrap;
import net.minecraft.util.Unit;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix3f;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.joml.Vector3fc;
/**
* Dumps the vanilla Java entity models from an (unobfuscated, 26.x+) Minecraft CLIENT jar into the CEM-template
* JSON layout PixelPics loads ({@code {"models": {name: {"model": {<jem>}}}}}, the jem as a nested object).
*
* <p>Every model is instantiated through its real renderer and posed by its real {@code setupAnim} with a default
* (idle, adult, not aggressive) render state. Names: {@code <type>} (main model) plus {@code <type>/<extra>} for
* the renderer's other models and render-layer models. Part coordinates are ABSOLUTE in the CEM frame (x negated,
* y up with the ground at 24), every part carries its absolute pivot ({@code "absolute": true}).
*
* <p>Run via {@code tools/update-vanilla-models.sh <version>}, or directly:
* {@code java -cp <client jar + libraries> 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();
// Records which layer every baked root came from (models only keep the ModelPart).
EntityModelSet set = new EntityModelSet(roots) {
@Override
public ModelPart bakeLayer(ModelLayerLocation loc) {
ModelPart p = super.bakeLayer(loc);
BAKED.put(p, loc);
return p;
}
};
// Renderers only need the model set at construction; everything else may stay null.
EntityRendererProvider.Context ctx =
new EntityRendererProvider.Context(null, null, null, null, null, set, null, null, null, null, null);
Class<?> renderers = Class.forName("net.minecraft.client.renderer.entity.EntityRenderers");
Field provField = renderers.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;
}
dumpRenderer(key, r, true, out, entities, failed);
}
// Players & mannequins use the avatar renderers (one per skin model type: wide/slim).
Map<?, ?> avatars = (Map<?, ?>) renderers
.getMethod("createAvatarRenderers", EntityRendererProvider.Context.class)
.invoke(null, ctx);
for (Map.Entry<?, ?> a : avatars.entrySet()) {
String key = String.valueOf(a.getKey()).toLowerCase().equals("slim") ? "player_slim" : "player";
dumpRenderer(key, (EntityRenderer<?, ?>) a.getValue(), false, out, entities, failed);
}
// Every layer definition in its rest pose as "layer/<model>#<layer>" (special item models, block entities).
for (Map.Entry<ModelLayerLocation, LayerDefinition> le : roots.entrySet()) {
String name = "layer/" + le.getKey().model().getPath() + "#" + le.getKey().layer();
try {
out.putIfAbsent(name, entry(jem(le.getValue(), List.of(), le.getValue().bakeRoot(), null)));
} catch (Throwable t) {
failed.add(name + " (layer: " + t + ")");
}
}
dumpPosedBlockEntities(set, out, failed);
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());
JsonObject rts = new JsonObject();
RENDER_TYPES.forEach(rts::add);
root.add("renderTypes", rts);
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);
}
/**
* Models and entity info of one renderer. {@code mob}: the renderer's current {@code model} field is the main
* model whatever else holds it (bigModel, …), plus scale/default textures ({@link #entityInfo}); avatars skip both.
*/
static void dumpRenderer(
String key,
EntityRenderer<?, ?> r,
boolean mob,
Map<String, JsonObject> out,
Map<String, JsonObject> entities,
List<String> failed) {
Object state = freshState(r, List.of());
Map<String, Model<?>> found = new LinkedHashMap<>();
Map<String, String> textures = new LinkedHashMap<>();
if (mob && 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 = mob ? entityInfo(r, state) : new JsonObject();
addLayerInfo(info, layers, r);
addTransform(info, r);
addPoseVariants(info, found, key, r);
addRotationBindings(info, found, r);
Map<Model<?>, String> types = addRenderInfo(info, layers, found, key, r);
entities.put(key, info);
emit(key, found, textures, state, types, out, failed);
}
/**
* Block-entity models posed by their own setupAnim with the static state the vanilla BlockEntityRenderer uses
* ({@code posed/<layer>}); rest-pose-only models (chest, bell, shulker box, pot, conduit) use {@code layer/…}.
*/
static void dumpPosedBlockEntities(EntityModelSet set, Map<String, JsonObject> out, List<String> failed) {
// Skulls at animation 0 (dragon jaw, piglin ears), banner flags at wave phase 0 (item/static pose).
posed(out, failed, set, ModelLayers.DRAGON_SKULL, null, DragonHeadModel::new, new SkullModelBase.State());
posed(out, failed, set, ModelLayers.PIGLIN_HEAD, null, PiglinHeadModel::new, new SkullModelBase.State());
posed(out, failed, set, ModelLayers.STANDING_BANNER_FLAG, null, BannerFlagModel::new, 0.0F);
posed(out, failed, set, ModelLayers.WALL_BANNER_FLAG, null, BannerFlagModel::new, 0.0F);
try {
Field bookState = Class.forName("net.minecraft.client.renderer.blockentity.LecternRenderer")
.getDeclaredField("BOOK_STATE");
bookState.setAccessible(true);
posed(out, failed, set, ModelLayers.BOOK, null, BookModel::new, bookState.get(null));
} catch (ReflectiveOperationException e) {
failed.add("posed/book (" + e + ")");
}
// EnchantTableRenderer: BookModel.State.forAnimation(time 0, flip 0 → pages 0.1/0.9, open), closed (no
// player within 3 blocks) and fully open.
for (float open : new float[] {0F, 1F}) {
posed(out, failed, set, ModelLayers.BOOK,
"posed/enchanting_book_" + (open == 0 ? "closed" : "open") + "#main",
BookModel::new, BookModel.State.forAnimation(0F, 0.1F, 0.9F, open));
}
// CopperGolemStatueBlockRenderer: one baked layer per statue pose.
Map<String, ModelLayerLocation> statues = new LinkedHashMap<>();
statues.put("standing", ModelLayers.COPPER_GOLEM);
statues.put("running", ModelLayers.COPPER_GOLEM_RUNNING);
statues.put("sitting", ModelLayers.COPPER_GOLEM_SITTING);
statues.put("star", ModelLayers.COPPER_GOLEM_STAR);
statues.forEach((pose, loc) -> posed(out, failed, set, loc, "posed/copper_golem_statue_" + pose + "#main",
CopperGolemStatueModel::new, Unit.INSTANCE));
}
/** One block-entity model posed with {@code state}, stored as {@code name} (default {@code posed/<layer>}). */
static void posed(
Map<String, JsonObject> out,
List<String> failed,
EntityModelSet set,
ModelLayerLocation loc,
String name,
Function<ModelPart, Model<?>> factory,
Object state) {
if (name == null) name = "posed/" + loc.model().getPath() + "#" + loc.layer();
try {
JsonObject jem = dump(factory.apply(set.bakeLayer(loc)), state, name, failed);
if (jem != null) out.put(name, entry(jem));
} catch (Throwable t) {
failed.add(name + " (" + t + ")");
}
}
/**
* {@code layers: {LayerClass: drawnByDefault}}, measured by running each layer's real {@code submit} for the
* default state, plus {@code humanoidArmor: true} when one of them is a HumanoidArmorLayer.
*/
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 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);
}
/** Whether the layer submits anything for the default state; null = no state or submit threw before. */
static Boolean layerDrawsByDefault(Object layer, EntityRenderer<?, ?> r) {
Object state = freshState(r, List.of());
if (state == null) return null;
boolean[] submitted = {false};
Boolean ok = submit(layer, state, recordingCollector((m, a) -> {
if (m.getName().startsWith("submit")) submitted[0] = true;
}));
if (ok == null || (!ok && !submitted[0])) return null;
return submitted[0];
}
/** A SubmitNodeCollector proxy that reports every call and otherwise does nothing. */
static Object recordingCollector(BiConsumer<Method, Object[]> onCall) {
Class<?> collector;
try {
collector = Class.forName("net.minecraft.client.renderer.SubmitNodeCollector");
} catch (ClassNotFoundException e) {
throw new IllegalStateException(e);
}
return Proxy.newProxyInstance(
VanillaModelDump.class.getClassLoader(), new Class<?>[] {collector}, (px, m, a) -> {
if (m.getName().equals("order")) return px;
onCall.accept(m, a);
Class<?> rt = m.getReturnType();
if (rt == boolean.class) return false;
if (rt.isPrimitive() && rt != void.class) return 0;
return null;
});
}
/**
* Runs the real {@code submit} of a renderer (yaw/pitch from the state) or of one of its render layers against
* {@code collector}. Null = no submit method, false = it threw (submissions before the throw still count: some
* layers fail on the null context).
*/
static Boolean submit(Object target, Object state, Object collector) {
boolean layer = !(target instanceof EntityRenderer<?, ?>);
Method submit = null;
for (Method m : target.getClass().getMethods())
if (m.getName().equals("submit") && m.getParameterCount() == (layer ? 6 : 4) && !m.isBridge()) submit = m;
if (submit == null) return null;
try {
if (layer) submit.invoke(target, new PoseStack(), collector, 0xF000F0, state, 0f, 0f);
else submit.invoke(target, state, new PoseStack(), collector, null);
return true;
} catch (Throwable t) {
return false;
}
}
/** Render type name -> pipeline properties ({@link #renderTypeJson}); written as root "renderTypes". */
static final Map<String, JsonObject> RENDER_TYPES = new TreeMap<>();
/**
* {@link #STATE_POSES}-style assignments whose effect is a different body texture ({@code stateTextures}):
* TippableArrowRenderer isTipped (Arrow.getColor() > 0), WitherBossRenderer invulnerableTicks > 80.
*/
static final String[][] TEXTURE_STATES = {
{"tipped", "isTipped", "true"},
{"invulnerable", "invulnerableTicks", "100"},
};
/** Assignments that switch on an emissive layer the default state leaves off (creaking eyes while active). */
static final String[][] LAYER_STATES = {
{"eyes_glowing", "eyesGlowing", "true"},
};
/** One recorded {@code submitModel} call: model, render type, vertex colour (ARGB, -1 = white). */
record Submission(Model<?> model, Object renderType, int color) {}
/** The model submissions of the renderer's ({@code layer == null}) or one layer's {@code submit}. */
@SuppressWarnings({"unchecked", "rawtypes"})
static List<Submission> submissions(EntityRenderer<?, ?> r, Object layer, Object state) {
List<Submission> out = new ArrayList<>();
Object proxy = recordingCollector((m, a) -> {
if (!m.getName().equals("submitModel") || a == null) return;
Model<?> model = null;
Object rt = null;
for (Object arg : a) {
if (arg instanceof Model<?> mm && model == null) model = mm;
else if (arg instanceof RenderType t && rt == null) rt = t;
else if (arg instanceof Identifier id && rt == null && model != null)
rt = ((Model) model).renderType(id);
}
int color = a.length >= 9 && a[6] instanceof Integer c ? c : -1;
if (model != null && rt != null) out.add(new Submission(model, rt, color));
});
submit(layer == null ? r : layer, state, proxy);
return out;
}
/** Name of a RenderType (registered in {@link #RENDER_TYPES} with its pipeline properties). */
static String renderTypeName(Object renderType) {
String name = (String) fieldValue(renderType, "name");
if (name == null) return null;
RENDER_TYPES.computeIfAbsent(name, n -> renderTypeJson(renderType));
return name;
}
/** The render type a model draws with by default ({@code Model.renderType(texture)}). */
@SuppressWarnings({"unchecked", "rawtypes"})
static String renderTypeName(Model<?> model) {
try {
return renderTypeName(((Model) model).renderType(Identifier.withDefaultNamespace("textures/entity/dummy.png")));
} catch (Throwable t) {
return null;
}
}
/**
* Pipeline properties: {@code cutout} (shader ALPHA_CUTOUT threshold), {@code blend} ("translucent" or
* "additive"; absent = opaque), {@code emissive} (no lightmap) and {@code shaded} (false with
* NO_CARDINAL_LIGHTING: no directional face shading).
*/
static JsonObject renderTypeJson(Object renderType) {
JsonObject o = new JsonObject();
RenderPipeline p = (RenderPipeline) fieldValue(fieldValue(renderType, "state"), "pipeline");
if (p == null) return o;
o.addProperty("pipeline", p.getLocation().getPath());
ShaderDefines defs = p.getShaderDefines();
String cut = defs.values().get("ALPHA_CUTOUT");
if (cut != null) o.addProperty("cutout", Float.parseFloat(cut.replace("f", "").replace("F", "")));
for (ColorTargetState c : p.getColorTargetStates()) {
if (c == null || c.blendFunction().isEmpty()) continue;
o.addProperty("blend", c.blendFunction().get().equals(BlendFunction.ADDITIVE) ? "additive" : "translucent");
}
o.addProperty("emissive", defs.flags().contains("EMISSIVE"));
o.addProperty("shaded", !defs.flags().contains("NO_CARDINAL_LIGHTING"));
return o;
}
/** Texture a render type samples as Sampler0, or null. */
@SuppressWarnings("unchecked")
static String renderTypeTexture(Object renderType) {
Object setup = fieldValue(renderType, "state");
Map<String, ?> tex = setup == null ? null : (Map<String, ?>) fieldValue(setup, "textures");
Object binding = tex == null ? null : tex.get("Sampler0");
Object id = binding == null ? null : fieldValue(binding, "location");
return id == null ? null : texturePath(id);
}
/** Texture path without namespace, "textures/" and ".png". */
static String texturePath(Object id) {
return id.toString().replaceFirst("^minecraft:", "").replaceFirst("^textures/", "").replaceFirst("\\.png$", "");
}
static boolean emissive(Object renderType) {
JsonObject o = RENDER_TYPES.get(renderTypeName(renderType));
return o != null && o.has("emissive") && o.get("emissive").getAsBoolean();
}
/**
* Render info of one renderer: the render type of every model the default state submits (returned, written
* per model by {@link #emit}), {@code emissive} layers and {@code stateTextures} ({@link #TEXTURE_STATES}).
*
* <p>{@code emissive: [{model, texture, renderType, alpha[, textures{<enum constant>: texture}][, when][,
* invisible][, animated]}]}: every layer that submits with an EMISSIVE pipeline (eyes, warden glow, copper-golem /
* creaking eyes) with the vertex alpha of its alpha function, texture variants per render-state enum constant
* (copper weathering), the {@link #LAYER_STATES} flag it needs, whether it still draws on an invisible entity
* and whether its alpha changes with the entity age (warden pulsating spots).
*/
static Map<Model<?>, String> addRenderInfo(
JsonObject info, Object layers, Map<String, Model<?>> found, String key, EntityRenderer<?, ?> r) {
Map<Model<?>, String> types = new IdentityHashMap<>();
Object st0 = freshState(r, List.of());
if (st0 == null) return types;
for (Submission sub : submissions(r, null, st0)) types.putIfAbsent(sub.model(), renderTypeName(sub.renderType()));
// Equipment (armor, elytra, saddles, body armor, harnesses) is drawn by the EquipmentLayerRenderer with
// RenderTypes.armorCutoutNoCull, whatever the model's own render type (PlayerModel/PiglinModel: translucent).
if (layers instanceof List<?> ls) {
String armor = renderTypeName((Object) RenderTypes.armorCutoutNoCull(
Identifier.withDefaultNamespace("textures/entity/dummy.png")));
for (Object layer : ls) {
boolean equipment = false;
for (Class<?> c = layer.getClass(); c != null && c != Object.class; c = c.getSuperclass())
for (Field f : c.getDeclaredFields())
equipment |= f.getType().getSimpleName().equals("EquipmentLayerRenderer");
if (!equipment) continue;
Map<String, Model<?>> mine = new LinkedHashMap<>();
collectModels(layer, "", mine, 0);
for (Model<?> m : mine.values()) types.put(m, armor);
}
}
Map<Model<?>, String> names = new IdentityHashMap<>();
for (Map.Entry<String, Model<?>> m : found.entrySet()) names.putIfAbsent(m.getValue(), modelName(key, m.getKey()));
JsonArray emissive = new JsonArray();
Set<String> seen = new HashSet<>();
if (layers instanceof List<?> ls) {
for (Object layer : ls) {
String[] when = null;
List<Submission> subs = emissiveSubmissions(r, layer, freshState(r, List.of()));
if (subs.isEmpty()) {
for (String[] a : LAYER_STATES) {
if (!assignmentApplies(st0, a)) continue;
subs = emissiveSubmissions(r, layer, freshState(r, List.<String[]>of(a)));
if (!subs.isEmpty()) {
when = a;
break;
}
}
}
List<String[]> base = when == null ? List.of() : List.<String[]>of(when);
for (Submission sub : subs) {
String name = names.get(sub.model());
String tex = renderTypeTexture(sub.renderType());
if (name == null || tex == null || !seen.add(name + "|" + tex)) continue;
JsonObject e = new JsonObject();
e.addProperty("model", name);
e.addProperty("texture", tex);
e.addProperty("renderType", renderTypeName(sub.renderType()));
e.addProperty("alpha", Math.round((sub.color() >>> 24) / 255f * 1000f) / 1000f);
if (when != null) e.addProperty("when", when[0]);
// Animated alpha (pulsating warden spots, …): a photo has no age, such layers are skipped.
boolean animated = false;
for (float age : new float[] {10, 25, 40, 63, 90}) {
Object st = freshState(r, base);
setFloat(st, "ageInTicks", age);
int a = -2;
for (Submission s2 : emissiveSubmissions(r, layer, st))
if (s2.model() == sub.model()) a = s2.color() >>> 24;
if (a != (sub.color() >>> 24)) animated = true;
}
if (animated) e.addProperty("animated", true);
Object inv = freshState(r, base);
setBoolean(inv, "isInvisible", true);
if (emissiveSubmissions(r, layer, inv).stream().anyMatch(s2 -> s2.model() == sub.model()))
e.addProperty("invisible", true);
JsonObject variants = textureVariants(r, layer, sub.model(), base, tex);
if (!variants.isEmpty()) e.add("textures", variants);
emissive.add(e);
}
}
}
if (!emissive.isEmpty()) info.add("emissive", emissive);
JsonObject stateTex = new JsonObject();
String def = textureOf(r, st0);
for (String[] a : TEXTURE_STATES) {
if (!assignmentApplies(st0, a)) continue;
String t = textureOf(r, freshState(r, List.<String[]>of(a)));
if (t != null && !t.equals(def)) stateTex.addProperty(a[0], t);
}
if (!stateTex.isEmpty()) info.add("stateTextures", stateTex);
return types;
}
static List<Submission> emissiveSubmissions(EntityRenderer<?, ?> r, Object layer, Object state) {
if (state == null) return List.of();
return submissions(r, layer, state).stream().filter(s -> emissive(s.renderType())).toList();
}
/** Textures an emissive layer uses per constant of the render state's enum fields (when they differ). */
static JsonObject textureVariants(EntityRenderer<?, ?> r, Object layer, Model<?> model, List<String[]> base, String def) {
JsonObject out = new JsonObject();
Object probe = freshState(r, base);
for (Field f : probe.getClass().getFields()) {
if (Modifier.isStatic(f.getModifiers()) || !f.getType().isEnum()) continue;
for (Object c : f.getType().getEnumConstants()) {
Object st = freshState(r, base);
try {
f.set(st, c);
} catch (Throwable t) {
continue;
}
for (Submission s : emissiveSubmissions(r, layer, st)) {
if (s.model() != model) continue;
String t = renderTypeTexture(s.renderType());
if (t != null && !t.equals(def)) out.addProperty(((Enum<?>) c).name().toLowerCase(), t);
}
}
}
return out;
}
/** The renderer's body texture for a state ({@code getTextureLocation}), or null. */
static String textureOf(EntityRenderer<?, ?> r, Object state) {
if (state == null) return null;
try {
Method gt = declaredMethod(r, m -> m.getName().equals("getTextureLocation") && m.getParameterCount() == 1);
if (gt == null) return null;
Object id = gt.invoke(r, state);
return id == null ? null : texturePath(id);
} catch (Throwable t) {
return null;
}
}
/**
* Non-bridge method matching {@code test} in the nearest class (from the object's class up) that declares one;
* of several there the LAST in declaration order (ShulkerRenderer also has a static
* getTextureLocation(DyeColor), which then wins and fails: the dump has no shulker texture).
*/
static Method declaredMethod(Object o, java.util.function.Predicate<Method> test) {
for (Class<?> c = o.getClass(); c != null; c = c.getSuperclass()) {
Method found = null;
for (Method m : c.getDeclaredMethods()) if (!m.isBridge() && test.test(m)) found = m;
if (found != null) {
found.setAccessible(true);
return found;
}
}
return null;
}
/**
* The built-in data-driven mob variants ({@code data/minecraft/<type>_variant/<key>.json}) with their client
* assets: {@code variants: {<type>: {<key>: {asset_id, baby_asset_id, assets, baby_assets, model}}}}.
*/
static JsonObject registryVariants() throws IOException {
JsonObject out = new JsonObject();
String jar = SharedConstants.class.getProtectionDomain().getCodeSource().getLocation().getPath();
Pattern p = Pattern.compile("data/minecraft/([a-z0-9_]+)_variant/([a-z0-9_/.-]+)\\.json");
try (ZipFile zf = new ZipFile(new File(jar))) {
List<? extends ZipEntry> entries =
zf.stream().sorted(Comparator.comparing(ZipEntry::getName)).toList();
for (ZipEntry ze : entries) {
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 (Reader rd = new InputStreamReader(zf.getInputStream(ze))) {
v = 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;
}
/**
* Name-based tweaks so the default render state shows the idle look: size 1 (slimes/magma cubes: a fresh state
* has 0), illagers with crossed arms (their peaceful pose), worn elytra at the ElytraAnimationState idle
* targets (a never-ticked state leaves the wings flat).
*/
static void idleTweaks(Object state) {
if (state == null) return;
try {
Field f = state.getClass().getField("size");
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) {
}
if (hasField(state, "elytraRotX") && hasField(state, "elytraRotZ")) {
setFloat(state, "elytraRotX", (float) (Math.PI / 12));
setFloat(state, "elytraRotZ", (float) (-Math.PI / 12));
}
}
/** Writes every collected model of one entity type. */
static void emit(
String key,
Map<String, Model<?>> found,
Map<String, String> textures,
Object state,
Map<Model<?>, String> renderTypes,
Map<String, JsonObject> out,
List<String> failed) {
for (Map.Entry<String, Model<?>> m : found.entrySet()) {
String name = modelName(key, m.getKey());
if (out.containsKey(name)) continue;
JsonObject jem = dump(m.getValue(), state, name, failed);
if (jem == null) continue;
String tex = textures.get(m.getKey());
if (tex != null) jem.addProperty("texture", tex);
String rt = renderTypes.get(m.getValue());
if (rt == null) rt = renderTypeName(m.getValue());
if (rt != null) jem.addProperty("renderType", rt);
out.put(name, entry(jem));
}
}
static boolean isMainField(String field) {
return field.equals("model") || field.equals("adultModel") || field.equals("normalModel");
}
static Model<?> mainModel(Map<String, Model<?>> found) {
return found.containsKey("model") ? found.get("model") : found.get("adultModel");
}
/** Render-layer models of the adult or the baby body. */
static List<Model<?>> layerModels(Map<String, Model<?>> found, boolean baby) {
List<Model<?>> out = new ArrayList<>();
for (Map.Entry<String, Model<?>> lm : found.entrySet())
if (lm.getKey().contains("Layer.") && lm.getKey().toLowerCase().contains("baby") == baby)
out.add(lm.getValue());
return out;
}
/** Dump name of a collected model field of entity type {@code key}. */
static String modelName(String key, String field) {
if (isMainField(field)) return key;
return field.equals("babyModel") ? key + "/baby" : key + "/" + field;
}
/**
* 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() + ".";
if (fieldValue(layer, "layer") instanceof Enum<?> en) prefix = "equip." + en.name().toLowerCase() + ".";
// Several layers of one class (warden: 5 LivingEntityEmissiveLayers): "<Layer>#2.", …
String base = prefix;
for (int n = 2; !prefix.startsWith("equip.") && hasPrefix(found, prefix); n++) {
prefix = base.substring(0, base.length() - 1) + "#" + n + ".";
}
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;
if (fl.getName().toLowerCase().contains("baby")) {
if (baby == null) baby = texturePath(v);
} else if (adult == null) adult = texturePath(v);
} 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);
}
}
static boolean hasPrefix(Map<String, Model<?>> found, String prefix) {
for (String k : found.keySet()) if (k.startsWith(prefix)) return true;
return false;
}
/**
* Renderer transform at yaw=pitch=0 ({@code transform}: the pose of its first model submission, column-major,
* Java model units -> entity-local blocks: end-crystal float, minecart offsets, wither x2, …) plus how yaw and
* pitch enter it, measured 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) {
Transform t = transform(r, null, false);
if (t != null) t.write(info);
}
/** {@code yawSign} null = the yaw-90 capture failed. */
record Transform(float[] matrix, Integer yawSign, float[] pitchAxis) {
int yaw() {
return yawSign == null ? 0 : yawSign;
}
boolean same(Transform o) {
for (int i = 0; i < 16; i++) if (Math.abs(matrix[i] - o.matrix[i]) > 1e-4f) return false;
if (yaw() != o.yaw() || (pitchAxis == null) != (o.pitchAxis == null)) return false;
if (pitchAxis != null)
for (int i = 0; i < 3; i++) if (Math.abs(pitchAxis[i] - o.pitchAxis[i]) > 1e-3f) return false;
return true;
}
void write(JsonObject o) {
JsonArray ta = new JsonArray();
for (float v : matrix) ta.add(Math.round(v * 100000f) / 100000f);
o.add("transform", ta);
if (yawSign != null) o.addProperty("yawSign", yawSign);
if (pitchAxis != null) o.add("pitchAxis", arr(pitchAxis));
}
}
/** Renderer transform for a state ({@code assigns}, see {@link #captureTransform}); null = none. */
static Transform transform(EntityRenderer<?, ?> r, List<String[]> assigns, boolean baby) {
float[] t = captureTransform(r, 0, 0, assigns, baby);
if (t == null) return null;
Matrix3f inv0 = new Matrix4f().set(t).get3x3(new Matrix3f()).invert();
Integer yaw = null;
float[] ty = captureTransform(r, 90, 0, assigns, baby);
if (ty != null) {
Matrix3f rel = new Matrix4f().set(ty).get3x3(new Matrix3f()).mul(inv0);
float sinA = -rel.m02; // rel ≈ RotY(±90)
yaw = Math.abs(sinA) < 0.5f ? 0 : (sinA > 0 ? 1 : -1);
}
float[] axis = null;
float[] tp = captureTransform(r, 0, 90, assigns, baby);
if (tp != null) {
Matrix3f rel = new Matrix4f().set(tp).get3x3(new 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) axis = new float[] {ax / len, ay / len, az / len};
}
return new Transform(t, yaw, axis);
}
/**
* Named STATE poses (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
{"angry", "isAngry", "true"}, // wolf: tail straight (WolfModel), raised (Wolf.getTailAngle)
{"angry", "tailAngle", "1.5393804"},
};
/** 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"));
/** Every subset (in bitmask order, elements in list order) with at least {@code minSize} elements. */
static <T> List<List<T>> subsets(List<T> items, int minSize) {
List<List<T>> out = new ArrayList<>();
for (int mask = 0; mask < (1 << items.size()); mask++) {
if (Integer.bitCount(mask) < minSize) continue;
List<T> sub = new ArrayList<>();
for (int i = 0; i < items.size(); i++) if ((mask & (1 << i)) != 0) sub.add(items.get(i));
out.add(sub);
}
return out;
}
static List<String[]> flatten(List<List<String[]>> groups) {
List<String[]> out = new ArrayList<>();
for (List<String[]> g : groups) out.addAll(g);
return out;
}
/**
* 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}), armor-stand arms / chests and their combinations, mob arm poses, and the
* {@link #STATE_POSES} (alone, in {@link #POSE_GROUPS} combinations and with held items). Stored 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 (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}.
*/
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 of one group 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);
// HumanoidModel.ArmPose.ITEM (arm raised by a held item) comes only from AvatarRenderer.getArmPose
// (players, mannequins): HumanoidMobRenderer extracts EMPTY (spear, aggressive bow/trident aside).
List<List<String[]>> items = new ArrayList<>();
if (r instanceof AvatarRenderer<?>) {
for (String[] hand : new String[][] {{"item_r", "rightArmPose"}, {"item_l", "leftArmPose"}})
if (isEnumField(probe, hand[1])) items.add(List.<String[]>of(new String[] {hand[0], hand[1], "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(List.<String[]>of(new String[] {f[0], f[1], "true"}));
}
List<List<String[]>> combos = new ArrayList<>();
for (List<List<String[]>> sub : subsets(toggles, 1)) combos.add(flatten(sub));
// Mob arm-pose enums (illagers: IllagerArmPose, idle CROSSED; piglins: PiglinArmPose, idle DEFAULT): every
// other constant (crossbow hold, attacking, admiring, …) as "arms_<constant>".
if (isEnumField(probe, "armPose")) {
Object idle = fieldValue(freshState(r, List.of()), "armPose");
for (Object c : fieldType(probe, "armPose").getEnumConstants()) {
if (c == idle) continue;
String v = ((Enum<?>) c).name();
List<String[]> combo = new ArrayList<>();
combo.add(new String[] {"arms_" + v.toLowerCase(), "armPose", v});
combos.add(combo);
if (!passenger.isEmpty()) {
List<String[]> riding = new ArrayList<>(combo);
riding.addAll(passenger);
combos.add(riding);
}
}
}
// State poses: alone, grouped, each with every held-item subset.
Set<String> applicable = new 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)
stateNames.addAll(subsets(g.stream().filter(applicable::contains).toList(), 2));
List<List<String[]>> stateCombos = new ArrayList<>();
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 (List<List<String[]>> held : subsets(items, 0)) {
List<String[]> c = new ArrayList<>(base);
c.addAll(flatten(held));
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.
Transform t0 = transform(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 = isMainField(f);
if (main && bm.getValue() != mainModel(found)) continue;
boolean baby = f.toLowerCase().contains("baby");
List<Model<?>> layerModels = layerModels(found, baby);
JsonObject poses = new JsonObject();
JsonObject transforms = new JsonObject();
Model<?> model = bm.getValue();
// Baby models are posed with a baby's state (isBaby, ageScale 0.5: e.g. wolf sitting offsets scale).
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, base, poseOf(model, freshState(r, c, babyState)), false);
}
Map<String, float[]> baseAll = withLayers(base, layerModels, freshState(r, List.of(), babyState));
Set<String> emitted = new 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));
Transform tr = transform(r, c, baby);
boolean newTransform = tr != null && t0 != null && !tr.same(t0);
String name = addPose(poses, c, baseAll, v, newTransform);
if (name == null) continue;
emitted.add(name);
if (newTransform) {
JsonObject o = new JsonObject();
new Transform(tr.matrix(), tr.yaw(), tr.pitchAxis()).write(o);
transforms.add(name, o);
}
}
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.
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(f.equals("babyModel") ? "baby" : f, 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, 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) {
// 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);
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;
String name = String.join("+", c.stream().map(t -> t[0]).distinct().sorted().toList());
poses.add(name, diff);
return name;
}
/** {@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;
}
/**
* 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, …), 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).
*/
static void addRotationBindings(JsonObject info, Map<String, Model<?>> found, EntityRenderer<?, ?> r) {
Object probe;
try {
probe = r.createRenderState();
} catch (Throwable t) {
return;
}
Model<?> main = mainModel(found);
if (main == null) return;
List<Model<?>> layers = layerModels(found, false);
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) {
Set<String> heads = new 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. */
static void headParts(ModelPart p, String name, boolean inHead, Set<String> out) {
inHead |= name.contains("head");
if (inHead) out.add(name);
for (Map.Entry<String, ModelPart> c : children(p).entrySet()) headParts(c.getValue(), c.getKey(), inHead, out);
}
@SuppressWarnings("unchecked")
static Map<String, ModelPart> children(ModelPart p) {
Map<String, ModelPart> c = (Map<String, ModelPart>) fieldValue(p, "children");
return c == null ? Map.of() : c;
}
/** 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;
Class<?> type = fieldType(st, a[1]);
if (type == null) return false;
if (a[2].startsWith("anim:")) return type.getSimpleName().equals("AnimationState");
if (type.isEnum()) {
for (Object c : type.getEnumConstants()) if (((Enum<?>) c).name().equals(a[2])) return true;
return false;
}
return type == boolean.class || type == float.class || type == int.class;
}
/** Type of a public field, or null. */
static Class<?> fieldType(Object o, String f) {
try {
return o.getClass().getField(f).getType();
} catch (NoSuchFieldException e) {
return null;
}
}
static boolean hasField(Object o, String f) {
return fieldType(o, f) != null;
}
/** An enum field (HumanoidModel.ArmPose), not e.g. an armor stand's Rotations. */
static boolean isEnumField(Object o, String f) {
Class<?> type = fieldType(o, f);
return type != null && type.isEnum();
}
static Object freshState(EntityRenderer<?, ?> r, List<String[]> sets) {
return freshState(r, sets, false);
}
/** A fresh idle render state with {@code sets} applied; {@code baby}: also isBaby + ageScale 0.5. */
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, rotation, visibility and scale, 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;
}
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
});
children(p).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 void setBoolean(Object o, String field, boolean v) {
try {
Field f = o.getClass().getField(field);
if (f.getType() == boolean.class) f.setBoolean(o, v);
} catch (Throwable ignored) {
}
}
/**
* Pose matrix (column-major) of the renderer's first model submission at the given yaw/pitch.
* {@code assigns != null}: a posed state ({@link #STATE_POSES}, isBaby when {@code baby}), then also the
* renderer's {@code getRenderOffset} (crouching players sink 2 px) like the EntityRenderDispatcher adds it.
*/
@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][];
submit(r, state, recordingCollector((m, a) -> {
if (a == null || first[0] != null || !m.getName().startsWith("submitModel")) return;
for (Object arg : a) {
if (arg instanceof PoseStack ps) {
first[0] = ps.last().pose().get(new float[16]);
break;
}
}
}));
if (assigns != null && first[0] != null) {
try {
Vec3 off = ((EntityRenderer) r).getRenderOffset((EntityRenderState) state);
if (off != null && off.lengthSqr() > 0) {
first[0] = new 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 = declaredMethod(r, dm -> dm.getName().equals("scale")
&& dm.getParameterCount() == 2
&& dm.getParameterTypes()[1].getSimpleName().equals("PoseStack"));
if (m != null) {
PoseStack ps = new PoseStack();
m.invoke(r, state, ps);
float sc = ps.last().pose().getScale(new 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}) {
Object st;
try {
st = r.createRenderState();
} catch (Throwable t) {
continue;
}
if (baby && !hasField(st, "isBaby")) continue;
setBoolean(st, "isBaby", baby);
String tex = textureOf(r, st);
if (tex != null) o.addProperty(baby ? "babyTexture" : "texture", tex);
}
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. */
static List<String> findPath(ModelPart from, ModelPart target, List<String> path) {
if (from == target) return path;
for (Map.Entry<String, ModelPart> c : children(from).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;
}
/** A (private) field's value, searched from the object's class up; null when missing or unreadable. */
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;
}
static JsonObject entry(JsonObject jem) {
JsonObject entry = new JsonObject();
entry.add("model", jem);
return entry;
}
/** One model posed with {@code state} (fallback: a fresh state of the model's own type) as a jem. */
@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;
}
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 {
m.invoke(model, m.getParameterTypes()[0].getDeclaredConstructor().newInstance());
posed = true;
break;
} catch (Throwable ignored) {
}
}
}
if (!posed) failed.add(name + " (rest pose: setupAnim failed)");
return jem(roots.get(loc), subPath, root, loc.model().getPath() + "#" + loc.layer());
}
/** The jem of a (sub-tree of a) layer definition with the parts' current pose; {@code layer} may be null. */
static JsonObject jem(LayerDefinition def, List<String> subPath, ModelPart root, String layer) {
PartDefinition rootDef = ((MeshDefinition) fieldValue(def, "mesh")).getRoot();
for (String seg : subPath) rootDef = rootDef.getChild(seg);
Object mat = fieldValue(def, "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);
if (layer != null) jem.addProperty("layer", 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")) {
Vector3fc org = (Vector3fc) fieldValue(c, "origin");
Vector3fc dim = (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");
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 ((boolean) fieldValue(c, "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()) {
subs.add(part(ce.getKey(), ce.getValue(), p.getChild(ce.getKey()), 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;
}
}