1505 lines
74 KiB
Java
1505 lines
74 KiB
Java
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;
|
||
}
|
||
}
|