entity state poses from the vanilla dump: sleeping/sitting/crouching/swimming/gliding, fox/cat/panda/parrot/bat/camel/sniffer poses with pose transforms, per-sub-model poses (baby pivots), armor-stand part poses and body pitch via rotation bindings, fish in water, beds, shoulder parrots, pose test scenes

This commit is contained in:
MineTec committed 2026-11-04 17:51:01 +01:00
1 parent 6660ebe557
commit 45d033bd89
10 files changed
+1108 -110

No files matched your search

@@ -39,9 +39,22 @@ public record EntityState(
ItemSpec headItem, // non-armor item worn in the head slot (pumpkin, skull, block, …); null = none
int collar, // ARGB dye of a tamed cat's/wolf's collar; 0 = no collar (untamed)
boolean powered, // energy-swirl layer: charged creeper, wither below half health
ItemSpec mouthItem // item carried in the mouth (fox/dolphin main hand); null = none
ItemSpec mouthItem, // item carried in the mouth (fox/dolphin main hand); null = none
String pose, // state pose flags, sorted and "+"-joined ("sitting", "lying+head_up", "sleeping", "in_water",
// …; the dump's STATE_POSES names, unknown ones are ignored per type); null = none (idle)
java.util.Map<String, float[]> partPoses, // continuous part poses by vanilla render-state field (armor stand
// "headPose"/"bodyPose"/"leftArmPose"/…: x/y/z degrees); null = defaults
String shoulderLeft, // parrot variant on a player's left shoulder (e.g. "red"); null = none
String shoulderRight // parrot variant on the right shoulder; null = none
) {
/** Whether the state pose flags contain {@code flag}. */
public boolean hasPose(String flag) {
if (pose == null) return false;
for (String f : pose.split("\\+")) if (f.equals(flag)) return true;
return false;
}
/** Worn armor (4 slots) of a humanoid wearer; any field may be null. */
public record Equipment(EquipPiece head, EquipPiece chest, EquipPiece legs, EquipPiece feet) {
public boolean isEmpty() {
@@ -111,23 +111,30 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (info == null) info = models.entityInfo(base);
boolean halfBaby = s.baby() && (ch == null || !ch.babyModel); // no dedicated baby model -> 0.5x adult
double extra = (halfBaby ? 0.5 : 1.0) * s.sizeScale();
SelectedPose sel = vanilla ? selectPose(s, info, ch) : SelectedPose.NONE;
Affine t0 = null;
Affine pre;
Affine place;
if (vanilla) {
// Vanilla placement: the renderer's own captured pose (incl. its scale / offsets), yaw and pitch
// applied the way that renderer applies them. CEM px -> Java model units: F·T(0,-24,0)/16.
// applied the way that renderer applies them — the pose's own when it changes them (sleeping in a
// bed, swimming, fish in water, …). CEM px -> Java model units: F·T(0,-24,0)/16.
CemModelLoader.PoseTransform pt = sel.transform();
boolean captured = info != null && info.transform() != null;
Affine t0 = captured ? Affine.fromColumnMajor(info.transform()) : DEFAULT_LIVING_TRANSFORM;
double[] tr = pt != null && pt.transform() != null ? pt.transform() : captured ? info.transform() : null;
t0 = tr != null ? Affine.fromColumnMajor(tr) : DEFAULT_LIVING_TRANSFORM;
pre = t0.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA);
int yawSign = captured ? info.yawSign() : -1;
int yawSign = pt != null ? pt.yawSign() : captured ? info.yawSign() : -1;
double[] pitchAxis = pt != null ? pt.pitchAxis() : info != null ? info.pitchAxis() : null;
place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.scale(extra))
.mul(Affine.rotY(yawSign * Math.toRadians(s.bodyYaw())));
// Projectiles (verified against ArrowRenderer/ThrownTridentRenderer 26.3): T0 = rotY(-90)[·rotZ(90)],
// pitchAxis = -X, so rotY(yaw)·rotAxis(-X, pitch)·T0 == vanilla rotY(yRot-90)·rotZ(xRot[+90]) with the
// raw projectile rotation (yaw atan2(dx,dz), pitch positive up); origin = entity position, no offset.
if (info != null && info.pitchAxis() != null && s.pitch() != 0) {
double[] a = info.pitchAxis();
// Mob poses: the pitch rotation follows the body yaw directly (swimming/gliding players, pouncing fox).
if (pitchAxis != null && s.pitch() != 0) {
double[] a = pitchAxis;
place = place.mul(Affine.rotAxis(a[0], a[1], a[2], Math.toRadians(s.pitch())));
}
} else {
@@ -136,7 +143,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
}
// Body: baked even when invisible — not drawn, but it yields the part frames (and legacy ground snap).
Pose pose = new Pose(pre, vanilla ? selectPose(s, info) : Map.of());
Pose pose = new Pose(pre, vanilla ? withBindings(sel.parts(), s, info) : Map.of());
Map<String, Affine> frames = new java.util.HashMap<>();
List<CemGeometry.Baked> body =
(model != null && tex != null) ? CemGeometry.bakePosed(model, tex, pre, pose.parts, frames) : List.of();
@@ -152,6 +159,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (vanilla && s.equipment() != null && info != null && info.humanoidArmor()) {
addArmorLayers(s, ch, pose, baked);
}
if (vanilla && !invisible) addShoulderParrots(s, ch, t0, baked);
List<EntityCube> itemCubes = new ArrayList<>();
if (vanilla && items != null) addItems(s, frames, isIllager(info), itemCubes);
if (baked.isEmpty() && glow.isEmpty() && itemCubes.isEmpty()) return invisible ? null : fallbackBox(s, tex);
@@ -184,16 +192,33 @@ public final class CemBaker implements EntityBaker<EntityState> {
}
}
/** A chosen pose variant: its name, part overrides and (when it changes it) the renderer transform. */
private record SelectedPose(
String name, Map<String, CemModelLoader.PartPose> parts, CemModelLoader.PoseTransform transform) {
static final SelectedPose NONE = new SelectedPose(null, Map.of(), null);
}
// Toggle flags that switch parts on/off or seat the entity; kept when held-item flags are dropped.
private static final java.util.Set<String> PART_TOGGLES = java.util.Set.of("passenger", "arms", "chest");
/**
* Picks the vanilla pose variant for the entity's state flags — the render-state fields vanilla's
* {@code setupAnim} reads, dumped per type: sitting ({@code passenger}), holding items ({@code item_r}/
* {@code item_l}), illagers' crossbow hold ({@code arms_*}), armor-stand arms ({@code arms}), chests
* ({@code chest}). Falls back to fewer flags when the exact combination has no own variant (identical to
* a simpler pose): first without the held-item flags (e.g. zombies' arms ignore held items), then with
* the part-toggling flags only.
* {@code setupAnim} / renderer read, dumped per type and per body model (baby/small models have their own
* pivots): sitting as a passenger ({@code passenger}), holding items ({@code item_r}/{@code item_l}),
* illagers' crossbow hold ({@code arms_*}), armor-stand arms ({@code arms}), chests ({@code chest}) and the
* state poses of {@link EntityState#pose()} (sleeping, sitting, crouching, lying, swimming, in_water, …;
* flags the type's dump doesn't know are ignored). Falls back to fewer flags when the exact combination has
* no own variant (identical to a simpler pose): state-pose subsets from all to none, each first with all
* other flags, then without the held-item flags (e.g. zombies' arms ignore held items), then with the
* part-toggling flags only.
*/
private static Map<String, CemModelLoader.PartPose> selectPose(EntityState s, CemModelLoader.EntityInfo info) {
if (info == null || info.poses().isEmpty()) return Map.of();
private SelectedPose selectPose(EntityState s, CemModelLoader.EntityInfo info, Choice ch) {
if (info == null) return SelectedPose.NONE;
String sub = ch == null || ch.name.equals(ch.base) || !ch.name.startsWith(ch.base + "/")
? null
: ch.name.substring(ch.base.length() + 1);
CemModelLoader.PoseSet set = info.poseSet(sub);
if (set.poses().isEmpty()) return SelectedPose.NONE;
List<String> flags = new ArrayList<>();
if (isIllager(info)) {
if (isCrossbow(s.rightHand()) || isCrossbow(s.leftHand())) flags.add("arms_crossbow_hold");
@@ -204,19 +229,99 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (s.passenger()) flags.add("passenger");
if (s.arms()) flags.add("arms");
if (s.chest()) flags.add("chest");
List<String> states = new ArrayList<>();
if (s.pose() != null) {
java.util.Set<String> known = knownPoseFlags(set);
for (String f : s.pose().split("\\+")) if (known.contains(f) && !states.contains(f)) states.add(f);
}
List<List<String>> tries = new ArrayList<>();
tries.add(flags);
tries.add(flags.stream().filter(f -> !f.startsWith("item_")).toList());
tries.add(flags.stream()
.filter(f -> f.equals("passenger") || f.equals("arms") || f.equals("chest"))
.toList());
for (List<String> st : subsetsBySize(states)) {
List<String> all = new ArrayList<>(st);
all.addAll(flags);
tries.add(all);
List<String> noItems = new ArrayList<>(st);
flags.stream().filter(f -> !f.startsWith("item_")).forEach(noItems::add);
tries.add(noItems);
List<String> toggles = new ArrayList<>(st);
flags.stream().filter(PART_TOGGLES::contains).forEach(toggles::add);
tries.add(toggles);
}
for (List<String> t : tries) {
if (t.isEmpty()) continue;
Map<String, CemModelLoader.PartPose> p =
info.poses().get(String.join("+", t.stream().sorted().toList()));
if (p != null) return p;
String name = String.join("+", t.stream().sorted().toList());
Map<String, CemModelLoader.PartPose> p = set.poses().get(name);
if (p != null) return new SelectedPose(name, p, set.transforms().get(name));
}
return Map.of();
return SelectedPose.NONE;
}
/** Every flag that occurs in a pose name of the set (cached per loaded pose map). */
private final Map<Map<String, Map<String, CemModelLoader.PartPose>>, java.util.Set<String>> knownFlags =
java.util.Collections.synchronizedMap(new java.util.IdentityHashMap<>());
private java.util.Set<String> knownPoseFlags(CemModelLoader.PoseSet set) {
return knownFlags.computeIfAbsent(set.poses(), poses -> {
java.util.Set<String> out = new java.util.HashSet<>();
for (String n : poses.keySet()) out.addAll(List.of(n.split("\\+")));
return out;
});
}
/** All subsets of {@code l}, largest first (input order within a size); the empty set last. */
private static List<List<String>> subsetsBySize(List<String> l) {
List<List<String>> out = new ArrayList<>();
int n = Math.min(l.size(), 6);
for (int size = n; size >= 0; size--) {
for (int mask = 0; mask < (1 << n); mask++) {
if (Integer.bitCount(mask) != size) continue;
List<String> sub = new ArrayList<>();
for (int i = 0; i < n; i++) if ((mask & (1 << i)) != 0) sub.add(l.get(i));
out.add(sub);
}
}
return out;
}
/**
* The pose overrides plus the dump's rotation bindings: armor-stand part poses ({@code headPose}, …, from
* {@link EntityState#partPoses()}) and body pitch ({@code xRot} = {@link EntityState#pitch()}: dolphins,
* striders, nautili), each added as {@code coeff · (value − default)} to the bound parts' rotation.
*/
private static Map<String, CemModelLoader.PartPose> withBindings(
Map<String, CemModelLoader.PartPose> parts, EntityState s, CemModelLoader.EntityInfo info) {
if (info == null || info.rotationBindings().isEmpty()) return parts;
Map<String, CemModelLoader.PartPose> out = null;
for (Map.Entry<String, CemModelLoader.RotationBinding> b :
info.rotationBindings().entrySet()) {
double[] value;
if (b.getKey().equals("xRot")) {
value = new double[] {s.pitch()};
} else {
float[] v = s.partPoses() == null ? null : s.partPoses().get(b.getKey());
if (v == null) continue;
value = new double[] {v[0], v[1], v[2]};
}
double[] def = b.getValue().def();
double[] delta = new double[3];
boolean any = false;
for (int j = 0; j < value.length && j < 3; j++) {
delta[j] = value[j] - (def != null && j < def.length ? def[j] : 0);
any |= Math.abs(delta[j]) > 1e-6;
}
if (!any) continue;
if (out == null) out = new java.util.HashMap<>(parts);
for (Map.Entry<String, double[]> pc : b.getValue().parts().entrySet()) {
double[] c = pc.getValue();
double[] add = new double[3];
for (int i = 0; i < 3; i++)
add[i] = c[i * 3] * delta[0] + c[i * 3 + 1] * delta[1] + c[i * 3 + 2] * delta[2];
CemModelLoader.PartPose cur = out.get(pc.getKey());
out.put(
pc.getKey(),
cur != null ? cur.plusRotation(add) : new CemModelLoader.PartPose(null, null, null, null, add));
}
}
return out == null ? parts : out;
}
private static boolean isCrossbow(ItemSpec i) {
@@ -274,25 +379,68 @@ public final class CemBaker implements EntityBaker<EntityState> {
/**
* Item carried in the mouth, rendered in the "ground" context like vanilla (HoldingEntityRenderState).
* Fox (FoxHeldItemLayer, awake): head pivot · [baby: S(0.75)] · head rotation · T(0.06, 0.27 [baby 0.26],
* -0.5) · rotX(90) — the head frame carries exactly that pivot + rotation. Dolphin (DolphinCarryingItemLayer)
* at the model root · T(0, 1, -1) (its xRot-dependent shift is 0: PixelPics dolphins aren't pitched).
* Fox (FoxHeldItemLayer): head PIVOT (the layer turns by the head-look angles, not the posed head part:
* PixelPics has no head look) · [baby: S(0.75)] · T(0.06, 0.27 [baby 0.26], -0.5) · rotX(90); asleep
* T(0.46, 0.26, 0.22) [baby (0.4, 0.26, 0.15)] · rotX(90) · rotZ(90). Dolphin (DolphinCarryingItemLayer) at
* the model root · T(0, 1 − a·0.5, −1 + a·0.5) looking up / T(0, 1 + a·0.8, −1 + a·0.2) looking down,
* a = |pitch| / 60.
*/
private void addMouthItem(EntityState s, Map<String, Affine> frames, List<EntityCube> out) {
ItemSpec item = s.mouthItem();
if (item == null) return;
boolean baby = s.baby();
Affine x = null;
if (s.typeKey().equals("fox") && frames.containsKey("head")) {
x = frames.get("head").mul(CemGeometry.JAVA_LOCAL);
String headPivot = CemGeometry.PIVOT + "head";
if (s.typeKey().equals("fox") && frames.containsKey(headPivot)) {
boolean asleep = s.hasPose("sleeping");
x = frames.get(headPivot).mul(CemGeometry.JAVA_LOCAL);
if (baby) x = x.mul(Affine.scale(0.75));
x = x.mul(Affine.translation(0.06, baby ? 0.26 : 0.27, -0.5)).mul(Affine.rotX(Math.PI / 2));
if (asleep) {
x = x.mul(baby ? Affine.translation(0.4, 0.26, 0.15) : Affine.translation(0.46, 0.26, 0.22))
.mul(Affine.rotX(Math.PI / 2))
.mul(Affine.rotZ(Math.PI / 2));
} else {
x = x.mul(Affine.translation(0.06, baby ? 0.26 : 0.27, -0.5)).mul(Affine.rotX(Math.PI / 2));
}
} else if (s.typeKey().equals("dolphin") && frames.containsKey("root")) {
x = frames.get("root").mul(CemGeometry.JAVA_LOCAL).mul(Affine.translation(0, 1, -1));
double a = Math.abs(s.pitch()) / 60.0;
Affine off = s.pitch() < 0
? Affine.translation(0, 1 - a * 0.5, -1 + a * 0.5)
: Affine.translation(0, 1 + a * 0.8, -1 + a * 0.2);
x = frames.get("root").mul(CemGeometry.JAVA_LOCAL).mul(off);
}
if (x != null) items.bake(item, "ground", false, x, out);
}
/**
* Parrots on a player's shoulders (vanilla ParrotOnShoulderLayer): the parrot model in its ON_SHOULDER pose,
* in the player's model space at T(±0.4, -1.5 [crouching -1.3], 0), textured by its variant.
*/
private void addShoulderParrots(EntityState s, Choice ch, Affine t0, List<CemGeometry.Baked> baked) {
if (t0 == null || (s.shoulderLeft() == null && s.shoulderRight() == null)) return;
if (ch == null || !ch.base.startsWith("player")) return;
CemModelLoader.CemModel pm = models.vanilla("parrot");
CemModelLoader.EntityInfo pi = models.entityInfo("parrot");
if (pm == null || pi == null) return;
Map<String, CemModelLoader.PartPose> parts = pi.poses().getOrDefault("on_shoulder", Map.of());
double y = s.hasPose("crouching") ? -1.3 : -1.5;
for (int side = 0; side < 2; side++) {
String variant = side == 0 ? s.shoulderLeft() : s.shoulderRight();
if (variant == null) continue;
int[][] tex = null;
for (ResourceLocation rl : EntityModels.textureCandidates("parrot", variant)) {
tex = texture(rl.path());
if (tex != null) break;
}
if (tex == null && pi.texture() != null) tex = texture(pi.texture());
if (tex == null) continue;
Affine pre = t0.mul(Affine.translation(side == 0 ? 0.4 : -0.4, y, 0))
.mul(Affine.scale(1 / 16.0))
.mul(CemGeometry.CEM_TO_JAVA);
baked.addAll(CemGeometry.bakePosed(pm, tex, pre, parts, null));
}
}
private static boolean isIllager(CemModelLoader.EntityInfo info) {
return info != null && info.poses().keySet().stream().anyMatch(k -> k.startsWith("arms_"));
}
@@ -81,6 +81,9 @@ final class CemGeometry {
/** Key prefix in the frames map marking parts that were actually drawn (visible). */
static final String DRAWN = "drawn:";
/** Key prefix in the frames map for a part's pivot frame {@code parent·T(P)} (without its own rotation). */
static final String PIVOT = "pivot:";
private record Ctx(
Set<String> hidden,
int texW,
@@ -107,31 +110,35 @@ final class CemGeometry {
private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx, boolean draw) {
if (ctx.hidden.contains(part.name())) return;
CemModelLoader.PartPose ov = ctx.overrides.get(part.name());
boolean invisible = ov != null ? !ov.visible() : part.invisible();
boolean invisible = ov != null && ov.visible() != null ? !ov.visible() : part.invisible();
if (invisible) {
if (ctx.frames == null) return;
draw = false;
}
if (ov != null && ov.pivot() == null) ov = null; // visibility-only override: applied above
double[] o = ov != null ? ov.pivot() : part.pivot();
double[] rot = ov != null ? ov.rotate() : part.rotate();
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
double[] scale = ov != null && ov.scale() != null ? ov.scale() : part.scale();
double[] add = ov == null ? null : ov.rotateAdd();
boolean moved = ov != null && ov.pivot() != null; // else visibility/scale/extra-rotation only
double[] o = moved ? ov.pivot() : part.pivot();
double[] rot = moved ? ov.rotate() : part.rotate();
if (add != null) rot = new double[] {rot[0] + add[0], rot[1] + add[1], rot[2] + add[2]};
Affine pivotFrame = parentWorld.mul(Affine.translation(o[0], o[1], o[2]));
Affine world = pivotFrame
.mul(Affine.rotZ(Math.toRadians(rot[2])))
.mul(Affine.rotY(Math.toRadians(rot[1])))
.mul(Affine.rotX(Math.toRadians(rot[0])));
if (part.scale() != null) {
if (scale != null) {
// A collapsed (zero) scale is vanilla's way to hide a part; it would also be non-invertible.
for (double v : part.scale()) if (Math.abs(v) < 1e-6) return;
world = world.mul(Affine.scale(part.scale()[0], part.scale()[1], part.scale()[2]));
for (double v : scale) if (Math.abs(v) < 1e-6) return;
world = world.mul(Affine.scale(scale[0], scale[1], scale[2]));
}
if (ctx.frames != null) {
ctx.frames.putIfAbsent(part.name(), world);
ctx.frames.putIfAbsent(PIVOT + part.name(), pivotFrame);
if (draw) ctx.frames.putIfAbsent(DRAWN + part.name(), world);
}
world = world.mul(Affine.translation(-o[0], -o[1], -o[2]));
Affine boxWorld = world;
if (ov != null) {
if (moved) {
// Boxes are stored relative to the idle pivot: move them along with the overridden pivot
// (children carry their own absolute pivots, so they use the un-shifted transform).
double[] p0 = part.pivot();
@@ -66,19 +66,54 @@ public final class CemModelLoader {
double[] pitchAxis, // world-frame axis the pitch rotates about; null = pitch ignored
Map<String, Map<String, PartPose>> poses, // pose ("passenger+item_r", "arms", "chest", …) -> overrides
Map<String, Boolean> layers, // render layer class -> drawn for the default (idle, unequipped) state
boolean humanoidArmor) { // the renderer has a HumanoidArmorLayer (wears humanoid armor)
boolean humanoidArmor, // the renderer has a HumanoidArmorLayer (wears humanoid armor)
Map<String, PoseTransform> poseTransforms, // pose -> renderer transform when it differs (sleeping, …)
Map<String, PoseSet> modelPoses, // body sub-model ("baby", "smallModel", …) -> its own poses
Map<String, RotationBinding> rotationBindings) { // state field ("headPose", "xRot", …) -> part rotations
/** Whether the vanilla renderer draws this layer for an entity in its default state. */
public boolean layerDrawnByDefault(String layerClass) {
return layers.getOrDefault(layerClass, false);
}
/** The poses of a body model: its own set when the dump has one, else the main model's. */
public PoseSet poseSet(String subModel) {
PoseSet own = subModel == null ? null : modelPoses.get(subModel);
return own != null ? own : new PoseSet(poses, poseTransforms);
}
}
/** Pose overrides + renderer transforms of one body model (see {@link EntityInfo#poseSet}). */
public record PoseSet(Map<String, Map<String, PartPose>> poses, Map<String, PoseTransform> transforms) {}
/**
* The renderer transform of a pose (like {@link EntityInfo#transform}/{@code yawSign}/{@code pitchAxis}):
* e.g. sleeping turns the body by the bed direction (yawSign +1), swimming pitches it.
*/
public record PoseTransform(double[] transform, int yawSign, double[] pitchAxis) {}
/**
* A continuous state input bound linearly to part rotations: {@code part rotate += coeff · (value − def)},
* {@code coeff} row-major 3×3 (row = CEM rotate axis, column = input component). Armor-stand part poses,
* body pitch.
*/
public record RotationBinding(double[] def, Map<String, double[]> parts) {}
/**
* A posed part override: absolute CEM pivot and CEM rotation (deg) — both null for a pure visibility
* toggle (the part keeps the pivot of whichever model it is in) — and visibility.
* toggle (the part keeps the pivot of whichever model it is in) — visibility (null = the part's own), scale
* (null = the part's own) and an extra rotation (deg, added to whichever rotation applies; null = none).
*/
public record PartPose(double[] pivot, double[] rotate, boolean visible) {}
public record PartPose(double[] pivot, double[] rotate, Boolean visible, double[] scale, double[] rotateAdd) {
/** This override plus an extra rotation (rotation bindings). */
public PartPose plusRotation(double[] add) {
double[] r = rotateAdd == null
? add
: new double[] {rotateAdd[0] + add[0], rotateAdd[1] + add[1], rotateAdd[2] + add[2]};
return new PartPose(pivot, rotate, visible, scale, r);
}
}
/**
* Client assets of a data-driven mob variant ({@code data/<ns>/<type>_variant/<key>.json}): the texture
@@ -190,9 +225,12 @@ public final class CemModelLoader {
doubles(o, "transform"),
o.has("yawSign") ? o.get("yawSign").getAsInt() : -1,
doubles(o, "pitchAxis"),
poses(o),
poses(o.getAsJsonObject("poses")),
layers(o),
o.has("humanoidArmor") && o.get("humanoidArmor").getAsBoolean()));
o.has("humanoidArmor") && o.get("humanoidArmor").getAsBoolean(),
poseTransforms(o.getAsJsonObject("poseTransforms")),
modelPoses(o.getAsJsonObject("modelPoses")),
rotationBindings(o.getAsJsonObject("rotationBindings"))));
}
if (root.has("variants")) {
for (Map.Entry<String, JsonElement> t :
@@ -315,10 +353,10 @@ public final class CemModelLoader {
return o.has("mirrorTexture") && o.get("mirrorTexture").getAsString().contains("u");
}
private static Map<String, Map<String, PartPose>> poses(JsonObject o) {
private static Map<String, Map<String, PartPose>> poses(JsonObject poses) {
Map<String, Map<String, PartPose>> out = new HashMap<>();
if (!o.has("poses")) return out;
for (Map.Entry<String, JsonElement> v : o.getAsJsonObject("poses").entrySet()) {
if (poses == null) return out;
for (Map.Entry<String, JsonElement> v : poses.entrySet()) {
Map<String, PartPose> parts = new HashMap<>();
for (Map.Entry<String, JsonElement> pe :
v.getValue().getAsJsonObject().entrySet()) {
@@ -328,13 +366,60 @@ public final class CemModelLoader {
new PartPose(
po.has("pivot") ? arr3(po, "pivot") : null,
po.has("rotate") ? arr3(po, "rotate") : null,
!po.has("visible") || po.get("visible").getAsBoolean()));
!po.has("visible") || po.get("visible").getAsBoolean(),
po.has("scale") ? arr3(po, "scale") : null,
null));
}
out.put(v.getKey(), parts);
}
return out;
}
private static Map<String, PoseTransform> poseTransforms(JsonObject o) {
Map<String, PoseTransform> out = new HashMap<>();
if (o == null) return out;
for (Map.Entry<String, JsonElement> e : o.entrySet()) {
JsonObject t = e.getValue().getAsJsonObject();
out.put(
e.getKey(),
new PoseTransform(
doubles(t, "transform"),
t.has("yawSign") ? t.get("yawSign").getAsInt() : -1,
doubles(t, "pitchAxis")));
}
return out;
}
private static Map<String, PoseSet> modelPoses(JsonObject o) {
Map<String, PoseSet> out = new HashMap<>();
if (o == null) return out;
for (Map.Entry<String, JsonElement> e : o.entrySet()) {
JsonObject m = e.getValue().getAsJsonObject();
out.put(
e.getKey(),
new PoseSet(
poses(m.getAsJsonObject("poses")), poseTransforms(m.getAsJsonObject("poseTransforms"))));
}
return out;
}
private static Map<String, RotationBinding> rotationBindings(JsonObject o) {
Map<String, RotationBinding> out = new HashMap<>();
if (o == null) return out;
for (Map.Entry<String, JsonElement> e : o.entrySet()) {
JsonObject b = e.getValue().getAsJsonObject();
Map<String, double[]> parts = new HashMap<>();
for (Map.Entry<String, JsonElement> p : b.getAsJsonObject("parts").entrySet()) {
JsonArray a = p.getValue().getAsJsonArray();
double[] c = new double[9];
for (int i = 0; i < 9 && i < a.size(); i++) c[i] = a.get(i).getAsDouble();
parts.put(p.getKey(), c);
}
out.put(e.getKey(), new RotationBinding(doubles(b, "default"), parts));
}
return out;
}
private static Map<String, Boolean> layers(JsonObject o) {
Map<String, Boolean> out = new HashMap<>();
if (o.has("layers")) {
@@ -26,6 +26,7 @@ import org.bukkit.entity.AbstractHorse;
import org.bukkit.entity.Ageable;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.Axolotl;
import org.bukkit.entity.Bat;
import org.bukkit.entity.Cat;
import org.bukkit.entity.ChestedHorse;
import org.bukkit.entity.Creeper;
@@ -44,7 +45,9 @@ import org.bukkit.entity.PufferFish;
import org.bukkit.entity.Rabbit;
import org.bukkit.entity.Sheep;
import org.bukkit.entity.Shulker;
import org.bukkit.entity.Sittable;
import org.bukkit.entity.Slime;
import org.bukkit.entity.Sniffer;
import org.bukkit.entity.Snowman;
import org.bukkit.entity.TraderLlama;
import org.bukkit.entity.Trident;
@@ -275,11 +278,35 @@ public final class EntitySnapshotBuilder {
// (vanilla dump), so the wearer list follows the game version.
EntityState.Equipment equipment = e instanceof LivingEntity wearer ? captureEquipment(wearer) : null;
String pose = poseFlags(e);
double x = loc.getX(), z = loc.getZ();
if (pose != null && pose.contains("sleeping") && e instanceof LivingEntity sleeper && !(e instanceof Fox)) {
// LivingEntityRenderer: a sleeper in a bed lies along the bed (yaw from the bed direction instead of
// the body yaw) and is shifted by its standing eye height - 0.1 towards the foot end.
org.bukkit.block.BlockFace bed = bedFacing(e);
if (bed != null) {
bodyYaw = switch (bed) {
case SOUTH -> 90;
case NORTH -> 270;
case EAST -> 180;
default -> 0;};
double off = standingEyeHeight(sleeper) - 0.1;
x -= bed.getModX() * off;
z -= bed.getModZ() * off;
}
}
java.util.Map<String, float[]> partPoses = e instanceof ArmorStand as ? armorStandPoses(as) : null;
String shoulderLeft = null, shoulderRight = null;
if (e instanceof HumanEntity he) {
shoulderLeft = shoulderParrot(he, true);
shoulderRight = shoulderParrot(he, false);
}
return new EntityState(
type,
loc.getX(),
x,
loc.getY(),
loc.getZ(),
z,
bodyYaw,
baby,
width,
@@ -308,7 +335,133 @@ public final class EntitySnapshotBuilder {
headItem,
collar,
powered,
mouthItem);
mouthItem,
pose,
partPoses,
shoulderLeft,
shoulderRight);
}
/**
* The entity's state-pose flags (names of the vanilla dump's STATE_POSES, sorted, "+"-joined; null = idle):
* the generic {@link Entity#getPose()} (sleeping, crouching, swimming/crawling, fall_flying, sitting,
* digging) plus the per-mob flags vanilla's renderers read from the entity (sitting pets/pandas/camels,
* fox sleeping/crouching/leaping/faceplanted, cat lying/head up/on a sleeping player, panda on its back,
* resting bat, standing/dancing parrot) and {@code in_water}. The baker ignores flags a type doesn't know.
*/
private static String poseFlags(Entity e) {
java.util.TreeSet<String> f = new java.util.TreeSet<>();
try {
switch (e.getPose()) {
case SLEEPING -> f.add("sleeping");
case SNEAKING -> f.add("crouching");
case SWIMMING -> f.add(e.isInWater() ? "swimming" : "crawling");
case FALL_FLYING -> f.add("fall_flying");
case SITTING -> f.add("sitting");
case DIGGING -> f.add("digging");
default -> {}
}
if (e instanceof LivingEntity le && le.isGliding()) f.add("fall_flying");
if (e.isInWater()) f.add("in_water");
if (e instanceof Sittable st && st.isSitting()) f.add("sitting");
if (e instanceof Fox fox) {
if (fox.isSleeping()) f.add("sleeping");
if (fox.isCrouching()) f.add("crouching");
if (fox.isLeaping()) f.add("pouncing");
if (fox.isFaceplanted()) f.add("faceplanted");
} else if (e instanceof Cat cat) {
if (cat.isLyingDown()) {
f.add("lying");
if (sleepingPlayerNear(cat)) f.add("on_player");
}
if (cat.isHeadUp()) f.add("head_up");
} else if (e instanceof Panda pa) {
if (pa.isOnBack()) f.add("on_back");
} else if (e instanceof Bat bat) {
if (!bat.isAwake()) f.add("resting");
} else if (e instanceof Parrot pa) {
// ParrotModel.getPose: party > sitting > flying (= not on the ground) > standing.
if (pa.isDancing()) f.add("party");
else if (!pa.isSitting() && pa.isOnGround()) f.add("standing");
} else if (e instanceof Sniffer sn && sn.getState() == Sniffer.State.DIGGING) {
f.add("digging");
}
} catch (Throwable ignored) {
// API missing on this server version: render the flags found so far
}
return f.isEmpty() ? null : String.join("+", f);
}
/** Cat.isLyingOnTopOfSleepingPlayer: a sleeping player within the cat's block box inflated by 2. */
private static boolean sleepingPlayerNear(Entity cat) {
Location l = cat.getLocation();
org.bukkit.util.BoundingBox box = new org.bukkit.util.BoundingBox(
l.getBlockX() - 2,
l.getBlockY() - 2,
l.getBlockZ() - 2,
l.getBlockX() + 3,
l.getBlockY() + 3,
l.getBlockZ() + 3);
for (Entity p : cat.getWorld().getNearbyEntities(box, en -> en instanceof Player)) {
if (((Player) p).isSleeping()) return true;
}
return false;
}
/**
* Eye height in the STANDING pose (vanilla state.eyeHeight of a sleeper): {@code getEyeHeight(true)} returns the
* sleeping pose's 0.2 on Paper 26.x, so ask the server entity ({@code getEyeHeight(Pose.STANDING)}).
*/
private static double standingEyeHeight(LivingEntity le) {
try {
Object handle = le.getClass().getMethod("getHandle").invoke(le);
for (java.lang.reflect.Method m : handle.getClass().getMethods()) {
if (!m.getName().equals("getEyeHeight") || m.getParameterCount() != 1) continue;
Class<?> pose = m.getParameterTypes()[0];
if (!pose.isEnum()) continue;
for (Object c : pose.getEnumConstants()) {
if (((Enum<?>) c).name().equals("STANDING")) return ((Number) m.invoke(handle, c)).doubleValue();
}
}
} catch (ReflectiveOperationException | RuntimeException ignored) {
// fall through
}
return le.getEyeHeight(true);
}
/** Facing of the bed the entity sleeps in (LivingEntity.getBedOrientation = the bed block's FACING), or null. */
private static org.bukkit.block.BlockFace bedFacing(Entity e) {
org.bukkit.block.data.BlockData bd = e.getLocation().getBlock().getBlockData();
return bd instanceof org.bukkit.block.data.type.Bed bed ? bed.getFacing() : null;
}
/** Armor-stand part poses in degrees, keyed by the vanilla render-state field (dump rotation bindings). */
private static java.util.Map<String, float[]> armorStandPoses(ArmorStand as) {
java.util.Map<String, float[]> out = new java.util.HashMap<>();
out.put("headPose", deg(as.getHeadPose()));
out.put("bodyPose", deg(as.getBodyPose()));
out.put("leftArmPose", deg(as.getLeftArmPose()));
out.put("rightArmPose", deg(as.getRightArmPose()));
out.put("leftLegPose", deg(as.getLeftLegPose()));
out.put("rightLegPose", deg(as.getRightLegPose()));
return out;
}
private static float[] deg(org.bukkit.util.EulerAngle a) {
return new float[] {
(float) Math.toDegrees(a.getX()), (float) Math.toDegrees(a.getY()), (float) Math.toDegrees(a.getZ())
};
}
/** Variant key of a parrot sitting on the player's left/right shoulder, or null. */
@SuppressWarnings("deprecation")
private static String shoulderParrot(HumanEntity he, boolean left) {
try {
Entity p = left ? he.getShoulderEntityLeft() : he.getShoulderEntityRight();
return p instanceof Parrot parrot ? keyOf(parrot.getVariant()) : null;
} catch (Throwable t) {
return null;
}
}
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */