From 9093c1ccfb3b417a6ecad39a5d9563f03644022b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 4 Nov 2026 21:15:38 +0100 Subject: [PATCH] =?UTF-8?q?christmas=20chest=20textures=20(24-26=20decembe?= =?UTF-8?q?r,=20chests=20and=20trapped=20chests,=20local=5Ftime=20item=20m?= =?UTF-8?q?odels,=20pixelpics.testDate=20test=20property),=20chest=20items?= =?UTF-8?q?=20from=20the=20item=20definition=20texture,=20shield=20banner?= =?UTF-8?q?=20patterns,=20item=20displays=20turned=20180=C2=B0=20like=20va?= =?UTF-8?q?nilla,=20dropped=20item=20lift=20from=20the=20ground=20model=20?= =?UTF-8?q?bounds,=20baby=20held-item=20offset,=20map=20decorations=20on?= =?UTF-8?q?=20framed=20vanilla=20maps=20(banners,=20frames,=20markers;=20n?= =?UTF-8?q?one=20on=20photo=20maps),=20inverted=20block=20elements=20in=20?= =?UTF-8?q?items=20and=20displays=20(spawner,=20vault=20inner=20faces),=20?= =?UTF-8?q?deterministic=20test=20scenes,=20items/xmas/marker=20test=20sce?= =?UTF-8?q?nes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../render/entity/BlockEntityModels.java | 10 +- .../render/entity/DecorationBaker.java | 26 ++ .../render/entity/DecorationState.java | 46 +++- .../pixelpics/render/entity/EntityCube.java | 9 + .../render/entity/EntityIntersector.java | 49 ++++ .../pixelpics/render/entity/ObjectBaker.java | 5 +- .../render/entity/cem/BlockEntityBaker.java | 26 +- .../pixelpics/render/entity/cem/CemBaker.java | 10 +- .../render/entity/cem/ItemGeometry.java | 138 +++++++--- .../snapshot/BlockEntitySnapshotBuilder.java | 36 +++ .../snapshot/DecorationSnapshotBuilder.java | 3 +- .../pixelpics/render/snapshot/ItemSpecs.java | 3 + .../pixelpics/render/snapshot/MapPixels.java | 60 ++++- .../snapshot/ObjectSnapshotBuilder.java | 9 +- .../pixelpics/render/util/SpecialDates.java | 44 +++ tools/BlockEntityTestRender.java | 30 +++ tools/edgecase-plugin/pptest/PPTest.java | 251 ++++++++++++++++-- 17 files changed, 681 insertions(+), 74 deletions(-) create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/render/util/SpecialDates.java diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java index 91326dd..041e6d6 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java @@ -1,5 +1,6 @@ package eu.mhsl.minecraft.pixelpics.render.entity; +import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -237,13 +238,18 @@ public final class BlockEntityModels { return s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind(); } + /** + * ChestRenderer.getChestMaterial + Sheets.chooseSprite: copper by stage, ender, CHRISTMAS for regular AND + * trapped chests between 24 and 26 December ({@link SpecialDates#isExtendedChristmas}), else normal/trapped. + */ private static List chestTextures(BlockEntityState s) { + boolean xmas = SpecialDates.isExtendedChristmas(); String base = switch (s.kind()) { - case TRAPPED_CHEST -> "trapped"; + case TRAPPED_CHEST -> xmas ? "christmas" : "trapped"; case ENDER_CHEST -> "ender"; case COPPER_CHEST -> s.colorName() == null ? "copper" : "copper_" + s.colorName(); - default -> "normal"; + default -> xmas ? "christmas" : "normal"; }; if (s.kind() == BlockEntityState.Kind.ENDER_CHEST) return List.of("entity/chest/ender"); return switch (chestKind(s)) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java index 0bb9c01..42f4c80 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java @@ -100,6 +100,7 @@ public final class DecorationBaker implements EntityBaker { faces[Direction.NORTH.ordinal()] = new Face(s.mapPixels(), 1, 1, 0, 0, 0, -1); faces[Direction.SOUTH.ordinal()] = new Face(s.mapPixels(), 0, 1, 1, 0, 0, -1); cubes.add(new EntityCube(new double[] {0, 0, -0.01}, new double[] {128, 128, -0.01}, faces, m)); + addMapMarkers(s, m, cubes); } else if (s.item() != null && items != null) { Affine m = content.mul(Affine.rotZ(Math.toRadians(s.rotation() * 45))).mul(Affine.scale(0.5)); @@ -108,6 +109,31 @@ public final class DecorationBaker implements EntityBaker { return cubes.isEmpty() ? null : RenderedEntity.of(cubes); } + /** + * MapRenderer decorations (only those shown on frames, see {@code MapPixels.markers}): in map pixel space + * {@code T(x/2 + 64, y/2 + 64, -0.02)·rotZ(rot·22.5°)·S(4, 4, 3)·T(-0.125, 0.125, 0)}, the sprite quad + * (-1,1)..(1,-1) at z = n·-0.001 (n-th drawn decoration), {@code map/decorations/} with v0 at +y. + */ + private void addMapMarkers(DecorationState s, Affine map, List cubes) { + int count = 0; + for (DecorationState.MapMarker mk : s.mapMarkers()) { + int[][] sprite = textures.get(ResourceLocation.parse("map/decorations/" + mk.asset())) + .orElse(null); + if (sprite != null) { + Affine m = map.mul(Affine.translation(mk.x() / 2.0 + 64, mk.y() / 2.0 + 64, -0.02)) + .mul(Affine.rotZ(Math.toRadians(mk.rot() * 360 / 16.0))) + .mul(Affine.scale(4, 4, 3)) + .mul(Affine.translation(-0.125, 0.125, 0)); + double z = count * -0.001; + Face[] faces = new Face[6]; + faces[Direction.SOUTH.ordinal()] = new Face(sprite, 0, 0, 1, 1, 0, -1); + faces[Direction.NORTH.ordinal()] = new Face(sprite, 1, 0, 0, 1, 0, -1); + cubes.add(new EntityCube(new double[] {-1, -1, z}, new double[] {1, 1, z}, faces, m)); + } + count++; + } + } + /** Vanilla Direction#toYRot for horizontal facings (south 0, west 90, north 180, east 270). */ private static double yRot(DecorationState.Facing f) { return switch (f) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java index 4f3ecb9..ca14a1a 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java @@ -1,5 +1,7 @@ package eu.mhsl.minecraft.pixelpics.render.entity; +import java.util.List; + /** * Immutable snapshot of a flat wall decoration (painting or item frame). These are Bukkit * {@code Hanging} entities, not block-entities, but render as flat textured quads rather than CEM @@ -21,8 +23,50 @@ public record DecorationState( int[][] mapPixels, // framed map's 128x128 ARGB colours (row-major [y][x]); null if not a map with data int rotation, // item-frame content rotation in 45° steps (0..7, vanilla ItemFrame#getRotation) boolean glow, // glow item frame - boolean invisible // invisible frame (setVisible(false)): no frame model, item sits on the wall + boolean invisible, // invisible frame (setVisible(false)): no frame model, item sits on the wall + List mapMarkers // framed map's decorations shown on item frames (banners, frames, …); may be empty ) { + + /** The layout without map markers (paintings, frames without a map or with an undecorated one). */ + public DecorationState( + Kind kind, + double minX, + double minY, + double minZ, + double maxX, + double maxY, + double maxZ, + Facing facing, + String paintingArt, + ItemSpec item, + int[][] mapPixels, + int rotation, + boolean glow, + boolean invisible) { + this( + kind, + minX, + minY, + minZ, + maxX, + maxY, + maxZ, + facing, + paintingArt, + item, + mapPixels, + rotation, + glow, + invisible, + List.of()); + } + + /** + * One map decoration (vanilla MapDecoration): sprite {@code map/decorations/}, position in half map + * pixels from the centre ({@code x}, {@code y} in -128..127), rotation in 16ths of a full turn. + */ + public record MapMarker(String asset, int x, int y, int rot) {} + public enum Kind { PAINTING, ITEM_FRAME diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java index f4d6c11..09c2de4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java @@ -15,6 +15,9 @@ public final class EntityCube { public final Affine toLocal; // inverse public final boolean additive; // emissive, added to what lies behind (energy swirl) instead of blended public final boolean unshaded; // no directional face shading (vanilla NO_CARDINAL_LIGHTING: eyes, breeze wind) + // Block-model element with from > to on some axis (spawner/vault inner faces): its faces point INTO the box + // and are back-face culled like vanilla quads (see EntityIntersector); mirrored = toWorld flips handedness. + public final boolean inverted, mirrored; public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; @@ -34,6 +37,12 @@ public final class EntityCube { this.faces = faces; this.toWorld = toWorld; this.toLocal = toWorld.inverse(); + this.inverted = from[0] > to[0] || from[1] > to[1] || from[2] > to[2]; + double[] r = toWorld.r; + double det = r[0] * (r[4] * r[8] - r[5] * r[7]) + - r[1] * (r[3] * r[8] - r[5] * r[6]) + + r[2] * (r[3] * r[7] - r[4] * r[6]); + this.mirrored = det < 0; for (int i = 0; i < 8; i++) { double x = (i & 1) == 0 ? from[0] : to[0]; double y = (i & 2) == 0 ? from[1] : to[1]; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java index b833608..f05dfc8 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java @@ -17,6 +17,7 @@ import org.bukkit.util.Vector; public final class EntityIntersector { private static final double EPS = 1e-7; + private static final Direction[] DIRECTIONS = Direction.values(); private EntityIntersector() {} @@ -55,6 +56,7 @@ public final class EntityIntersector { d[1] = r[3] * dx + r[4] * dy + r[5] * dz; d[2] = r[6] * dx + r[7] * dy + r[8] * dz; double[] from = cube.from, to = cube.to; + if (cube.inverted) return intersectInverted(cube, o, d, Math.max(EPS, minT), maxT, out); double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY; int inAxis = -1, outAxis = -1; @@ -110,6 +112,53 @@ public final class EntityIntersector { return true; } + /** + * A block-model element with {@code from > to} on some axis (spawner/vault/trial-spawner inner faces) baked as + * an item or display: like {@code ElementIntersector.invertedBox}, vanilla puts the WEST/DOWN/NORTH face at + * {@code from} and EAST/UP/SOUTH at {@code to} of its axis, and each inverted OTHER axis mirrors the quad and + * flips its winding — the faces point into the box and are back-face culled (seen through the cage: the far + * wall's inside). Nearest visible opaque face in {@code (lo, maxT]} wins; UVs follow the mirrored corners. + */ + private static boolean intersectInverted(EntityCube cube, double[] o, double[] d, double lo, double maxT, Hit out) { + double[] from = cube.from, to = cube.to; + double best = Double.POSITIVE_INFINITY; + for (Direction dir : DIRECTIONS) { + int a = dir.nx != 0 ? 0 : dir.ny != 0 ? 1 : 2; + if (Math.abs(d[a]) < EPS) continue; + boolean neg = dir.nx + dir.ny + dir.nz < 0; + int b = (a + 1) % 3, c = (a + 2) % 3; + int flips = (from[b] > to[b] ? 1 : 0) + (from[c] > to[c] ? 1 : 0); + double sign = (neg ? -1 : 1) * (flips % 2 == 0 ? 1 : -1) * (cube.mirrored ? -1 : 1); + if (sign * d[a] >= 0) continue; // back face + double t = ((neg ? from[a] : to[a]) - o[a]) / d[a]; + if (!(t > lo) || t > maxT || t >= best) continue; + double pb = o[b] + d[b] * t, pc = o[c] + d[c] * t; + if (pb < Math.min(from[b], to[b]) - EPS || pb > Math.max(from[b], to[b]) + EPS) continue; + if (pc < Math.min(from[c], to[c]) - EPS || pc > Math.max(from[c], to[c]) + EPS) continue; + Face face = cube.faces[dir.ordinal()]; + if (face == null) continue; + double fx = signedFrac(o[0] + d[0] * t, from[0], to[0]); + double fy = signedFrac(o[1] + d[1] * t, from[1], to[1]); + double fz = signedFrac(o[2] + d[2] * t, from[2], to[2]); + int color = face.sample(ElementIntersector.faceS(dir, fx, fy, fz), ElementIntersector.faceT(dir, fy, fz)); + if (!ColorUtil.opaque(color)) continue; + best = t; + out.t = t; + out.color = color; + out.axis = a; + out.neg = neg; + } + return best < Double.POSITIVE_INFINITY; + } + + /** Position between {@code from} (0) and {@code to} (1), also when {@code from > to}; clamped to 0..1. */ + private static double signedFrac(double v, double from, double to) { + double span = to - from; + if (Math.abs(span) < 1e-9) return 0; + double f = (v - from) / span; + return f < 0 ? 0 : Math.min(f, 1); + } + /** The result object for a hit of {@code cube} recorded by {@link #intersect} (world point + normal). */ public static FaceHit toFaceHit( EntityCube cube, Hit h, double ox, double oy, double oz, double dx, double dy, double dz) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java index 5c61e5c..e7c3b81 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java @@ -43,7 +43,10 @@ public final class ObjectBaker implements EntityBaker { case ITEM -> { Affine at = s.toWorld(); if (s.dropped()) { - double lift = 0.1 + 0.25 * items.displayScaleY(s.item(), s.context()); + // ItemEntityRenderer: T(0, bob + 0.0625 - modelBox.minY), bob = sin(age/10 + offset)·0.1 + 0.1 + // frozen at phase 0: the ground model's lowest point floats 0.1625 above the entity position. + double[] box = items.modelBounds(s.item(), s.context()); + double lift = 0.1 + 0.0625 - (box == null ? 0 : box[1]); at = Affine.translation(0, lift, 0).mul(at); } items.bake(s.item(), s.context(), false, at, cubes); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java index 3e02058..ff51167 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java @@ -424,18 +424,28 @@ public final class BlockEntityBaker implements EntityBaker { * (one geometry, no coplanar layers). */ private int[][] bannerFlag(int[][] base, BlockEntityState s) { - int baseColor = s.baseColorArgb(); - List patterns = List.copyOf(s.patterns()); - return derived.get( - DerivedTextureCache.key("banner", base, baseColor, patterns), - () -> buildBannerFlag(base, baseColor, patterns)); + return patterned(base, "entity/banner/", s.baseColorArgb(), s.patterns()); } - private int[][] buildBannerFlag(int[][] base, int baseColor, List patterns) { + /** + * BannerRenderer.submitPatterns composited into one texture: {@code base} (banner_base / shield_base), the + * {@code base} mask dyed with {@code baseColor} (0 = none), then the first 16 pattern masks + * ({@code }) dyed with their colours. {@code dir} = {@code entity/banner/} or {@code entity/shield/} + * (shields, ShieldSpecialRenderer). Cached per input. + */ + public int[][] patterned(int[][] base, String dir, int baseColor, List patterns) { + List pats = List.copyOf(patterns.subList(0, Math.min(16, patterns.size()))); + return derived.get( + DerivedTextureCache.key("banner", base, dir, baseColor, pats), + () -> buildPatterned(base, dir, baseColor, pats)); + } + + private int[][] buildPatterned( + int[][] base, String dir, int baseColor, List patterns) { int[][] out = TextureOps.deepCopy(base); - if (baseColor != 0) overlayMask(out, "entity/banner/base", baseColor); + if (baseColor != 0) overlayMask(out, dir + "base", baseColor); for (BlockEntityState.BannerPattern pat : patterns) { - overlayMask(out, "entity/banner/" + pat.patternKey(), pat.colorArgb()); + overlayMask(out, dir + pat.patternKey(), pat.colorArgb()); } return out; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java index 89d0015..bb9e3e5 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java @@ -405,7 +405,8 @@ public final class CemBaker implements EntityBaker { } /** - * Held items (vanilla ItemInHandLayer: hand frame · rotX(-90) · rotY(180) · T(±1/16, 0.125, -0.625), + * Held items (vanilla ItemInHandLayer: hand frame · rotX(-90) · rotY(180) · T(±1/16, 0.125, -0.625), babies + * T(0, 1/16, -4.5/16), * third-person display context), crossed-arms items (villagers/witches: root · T(0, 0.4, -0.4) · * rotX(180), ground context) and head items (CustomHeadLayer: blocks/items at 0.625 with the "head" * context, skulls at 1.1875 through the block-entity model). Frames are Java-model frames (blocks). @@ -421,10 +422,15 @@ public final class CemBaker implements EntityBaker { if (item == null) continue; Affine arm = frames.get(left ? "left_arm" : "right_arm"); if (arm != null && !crossed && !(illager && !frames.containsKey(d + "right_arm"))) { + // ItemInHandLayer.useBabyOffset (babies except armor stands): (0, 1, -4.5) px instead of (±1, 2, -10). + boolean babyOffset = s.baby() && !s.typeKey().equals("armor_stand"); Affine x = arm.mul(CemGeometry.JAVA_LOCAL) .mul(Affine.rotX(Math.toRadians(-90))) .mul(Affine.rotY(Math.PI)) - .mul(Affine.translation((left ? -1 : 1) / 16.0, 0.125, -0.625)); + .mul( + babyOffset + ? Affine.translation(0, 1 / 16.0, -4.5 / 16) + : Affine.translation((left ? -1 : 1) / 16.0, 0.125, -0.625)); items.bake(item, left ? "thirdperson_lefthand" : "thirdperson_righthand", left, x, out); } else if (crossed && !left && frames.containsKey("root")) { Affine x = frames.get("root") diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java index 6412fad..f926ece 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java @@ -22,14 +22,21 @@ import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps; import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint; import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; +import eu.mhsl.minecraft.pixelpics.render.util.SpecialDates; +import java.text.SimpleDateFormat; +import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TimeZone; import java.util.concurrent.ConcurrentHashMap; +import org.bukkit.DyeColor; import org.bukkit.block.data.BlockData; /** @@ -77,6 +84,7 @@ public final class ItemGeometry { List layers, // generated-item layer textures (layer0, layer1, …); empty if not generated List tints, // ARGB per layer (0 = none) String special, // special model type ("shield", "trident", "chest", "head", …) or null + JsonObject specialModel, // the special model's own fields (chest texture/chest_type, …) or null Affine specialTransform) {} // item-model transformation for special models (T·Rl·S·Rr) or null // --- public API -------------------------------------------------------------------------------- @@ -90,7 +98,10 @@ public final class ItemGeometry { if (info.special != null) bakeSpecial(item, info, m, out); if (out.size() == before && !info.layers.isEmpty()) bakeGenerated(info, m, out); if (out.size() == before && item.block() != null) bakeBlock(item.block(), m, out); - if (out.size() == before && item.blockEntity() != null) bakeBlockEntity(item.blockEntity(), m, out); + // Placed block-entity fallback for block items only (a shield carries its patterns as a banner state). + if (out.size() == before && item.block() != null && item.blockEntity() != null) { + bakeBlockEntity(item.blockEntity(), m, out); + } if (out.size() == before) { int[][] sprite = sprite(item.id()); if (sprite != null) addExtruded(sprite, m, out); @@ -130,13 +141,6 @@ public final class ItemGeometry { return box; } - /** The display transform's Y scale for a context (vanilla lifts dropped items by 0.25·scaleY). */ - public double displayScaleY(ItemSpec item, String context) { - if (item == null) return 1; - Info info = info(item.id(), context); - return info.display == null ? 1 : info.display[7]; - } - /** A block's model elements in the block cube [0,1]³ (tinted with plains colours). */ public void bakeBlock(BlockData data, Affine toWorld, List out) { if (data == null) return; @@ -174,30 +178,34 @@ public final class ItemGeometry { // --- item definition / model resolution -------------------------------------------------------- private Info info(String id, String context) { - return infos.computeIfAbsent(id + "#" + context, k -> { + // Keyed by date too: minecraft:local_time selects (christmas chest items) change with the day. + ZonedDateTime now = SpecialDates.now(); + return infos.computeIfAbsent(id + "#" + context + "#" + now.toLocalDate(), k -> { try { - return resolve(id, context); + return resolve(id, context, now); } catch (RuntimeException e) { // Unexpected item-definition format (newer version?): fall back to the plain sprite. - return new Info(null, List.of(), List.of(), null, null); + return new Info(null, List.of(), List.of(), null, null, null); } }); } - private Info resolve(String id, String context) { + private Info resolve(String id, String context, ZonedDateTime now) { String modelRef = null, special = null; + JsonObject specialModel = null; Affine specialXf = null; List tints = new ArrayList<>(); Optional def = reader.readJsonObject("assets/minecraft/items/" + id + ".json"); if (def.isPresent() && def.get().has("model")) { Affine[] outer = {null}; - JsonObject node = pick(def.get().getAsJsonObject("model"), context, outer); + JsonObject node = pick(def.get().getAsJsonObject("model"), context, now, outer); if (node != null) { String type = str(node, "type"); if (type.endsWith("special")) { modelRef = str(node, "base"); JsonObject sm = node.has("model") ? node.getAsJsonObject("model") : null; special = sm == null ? null : str(sm, "type").replace("minecraft:", ""); + specialModel = sm; // Like ItemModel.Unbaked.bake: the transformations of the enclosing select/condition nodes // compose with the special's own (shield/trident flips, copper golem statue placement). specialXf = outer[0]; @@ -249,14 +257,14 @@ public final class ItemGeometry { if (generated) { for (int i = 0; tex.containsKey("layer" + i); i++) layers.add(resolveTex(tex, tex.get("layer" + i))); } - return new Info(display, layers, tints, special, specialXf); + return new Info(display, layers, tints, special, specialModel, specialXf); } /** * Walks an item-model node to the concrete model/special for a display context; {@code outer[0]} * accumulates the {@code transformation}s of the nodes passed on the way (null = none). */ - private static JsonObject pick(JsonObject node, String context, Affine[] outer) { + private static JsonObject pick(JsonObject node, String context, ZonedDateTime now, Affine[] outer) { for (int depth = 0; node != null && depth < 16; depth++) { String type = str(node, "type").replace("minecraft:", ""); if (!type.equals("model") && !type.equals("special") && node.has("transformation")) { @@ -270,10 +278,14 @@ public final class ItemGeometry { case "condition" -> node = obj(node, "on_false"); case "select" -> { JsonObject chosen = null; - if (str(node, "property").endsWith("display_context") && node.has("cases")) { + String property = str(node, "property"); + String value = property.endsWith("display_context") + ? context + : property.endsWith("local_time") ? localTime(node, now) : null; + if (value != null && node.has("cases")) { for (JsonElement c : node.getAsJsonArray("cases")) { JsonObject co = c.getAsJsonObject(); - if (matches(co.get("when"), context)) { + if (matches(co.get("when"), value)) { chosen = obj(co, "model"); break; } @@ -304,6 +316,23 @@ public final class ItemGeometry { return null; } + /** + * The {@code minecraft:local_time} select value (LocalTime property: SimpleDateFormat {@code pattern} with + * optional {@code locale} and {@code time_zone}) for {@code now}; null when the format is invalid. + */ + private static String localTime(JsonObject node, ZonedDateTime now) { + try { + String locale = str(node, "locale"); + SimpleDateFormat f = new SimpleDateFormat( + str(node, "pattern"), locale.isEmpty() ? Locale.ROOT : Locale.forLanguageTag(locale)); + String tz = str(node, "time_zone"); + f.setTimeZone(tz.isEmpty() ? TimeZone.getTimeZone(now.getZone()) : TimeZone.getTimeZone(tz)); + return f.format(Date.from(now.toInstant())); + } catch (RuntimeException e) { + return null; + } + } + private static boolean matches(JsonElement when, String context) { if (when == null) return false; if (when.isJsonArray()) { @@ -384,33 +413,78 @@ public final class ItemGeometry { // --- geometry ------------------------------------------------------------------------------------ private void bakeSpecial(ItemSpec item, Info info, Affine m, List out) { + Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m; switch (info.special) { - case "shield", "trident" -> { - // Entity model in Java model space; the item definition's transformation flips it (1,-1,-1). - boolean shield = info.special.equals("shield"); - CemModelLoader.CemModel model = models.vanilla(shield ? "layer/shield#main" : "trident"); - if (model == null) model = models.vanilla("layer/" + info.special + "#main"); - int[][] tex = textures.get(ResourceLocation.parse( - shield ? "entity/shield/shield_base_nopattern" : "entity/trident/trident")) + case "shield" -> { + // ShieldSpecialRenderer: shield_base_nopattern, or (banner patterns / base colour present) + // shield_base + the dyed shield pattern masks (base colour default white); the item definition's + // transformation flips the model (1,-1,-1). + CemModelLoader.CemModel model = models.vanilla("layer/shield#main"); + int[][] tex = shieldTexture(item.blockEntity()); + if (model != null && tex != null) bakeEntityModel(model, tex, xf, out); + } + case "trident" -> { + CemModelLoader.CemModel model = models.vanilla("trident"); + if (model == null) model = models.vanilla("layer/trident#main"); + int[][] tex = textures.get(ResourceLocation.parse("entity/trident/trident")) .orElse(null); - if (model == null || tex == null) return; - Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m; - Affine pre = xf.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA); - for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, Set.of())) { - out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world())); + if (model != null && tex != null) bakeEntityModel(model, tex, xf, out); + } + case "chest" -> { + // ChestSpecialRenderer: the chest model of the item's chest_type with the item definition's + // texture (Sheets.CHEST_MAPPER: entity/chest/; christmas via the local_time select). + if (!bakeChest(info.specialModel, xf, out) && item.blockEntity() != null && blockEntities != null) { + blockEntities.bakeModel(item.blockEntity(), xf, out); } } default -> { - // Block-entity specials (chest, head, banner, shulker box, conduit, pot, …): like the vanilla - // special renderers, the bare model (no block pose) placed by the item's transformation. + // Block-entity specials (head, banner, shulker box, conduit, pot, …): like the vanilla special + // renderers, the bare model (no block pose) placed by the item's transformation. if (item.blockEntity() != null && blockEntities != null) { - Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m; blockEntities.bakeModel(item.blockEntity(), xf, out); } } } } + /** An entity model (Java model space, px) through {@code xf}. */ + private static void bakeEntityModel(CemModelLoader.CemModel model, int[][] tex, Affine xf, List out) { + Affine pre = xf.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA); + for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, Set.of())) { + out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world())); + } + } + + /** The chest special model from its JSON ({@code texture}, {@code chest_type}); false when unresolvable. */ + private boolean bakeChest(JsonObject sm, Affine xf, List out) { + if (sm == null || str(sm, "texture").isEmpty()) return false; + String layer = + switch (str(sm, "chest_type").replace("minecraft:", "")) { + case "left" -> "layer/double_chest_left#main"; + case "right" -> "layer/double_chest_right#main"; + default -> "layer/chest#main"; + }; + CemModelLoader.CemModel model = models.vanilla(layer); + ResourceLocation id = ResourceLocation.parse(str(sm, "texture")); + int[][] tex = textures.get(ResourceLocation.parse(id.namespace() + ":entity/chest/" + id.path())) + .orElse(null); + if (model == null || tex == null) return false; + bakeEntityModel(model, tex, xf, out); + return true; + } + + /** The shield texture for the stack's patterns ({@code BANNER} state from the item meta; null = none). */ + private int[][] shieldTexture(BlockEntityState patterns) { + boolean patterned = patterns != null + && (patterns.baseColorArgb() != 0 || !patterns.patterns().isEmpty()); + int[][] base = textures.get(ResourceLocation.parse( + patterned ? "entity/shield/shield_base" : "entity/shield/shield_base_nopattern")) + .orElse(null); + if (!patterned || base == null || blockEntities == null) return base; + int baseColor = patterns.baseColorArgb() != 0 ? patterns.baseColorArgb() : ColorUtil.dyeArgb(DyeColor.WHITE, 0); + return blockEntities.patterned(base, "entity/shield/", baseColor, patterns.patterns()); + } + private void bakeGenerated(Info info, Affine m, List out) { // Cached per Info (one per item+context): a stable texture identity keeps the extrusion cache bounded. int[][] composed = composedSprites diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java index ba59bf2..083ada6 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java @@ -44,6 +44,7 @@ import org.bukkit.entity.EntitySnapshot; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BannerMeta; import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.ShieldMeta; import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.profile.PlayerProfile; import org.bukkit.util.Vector; @@ -130,6 +131,41 @@ public final class BlockEntitySnapshotBuilder { return s; } + /** + * A shield's banner data (ShieldMeta: base colour + patterns) as a {@code BANNER} state at cell 0 for the + * shield special model; null without patterns and base colour (plain shield). + */ + public static BlockEntityState shieldPatterns(ItemMeta meta) { + try { + if (!(meta instanceof ShieldMeta shield)) return null; + DyeColor baseColor = shield.getBaseColor(); + List pats = new ArrayList<>(); + for (Pattern p : shield.getPatterns()) { + pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF))); + } + if (baseColor == null && pats.isEmpty()) return null; + return new BlockEntityState( + Kind.BANNER, + 0, + 0, + 0, + 0, + null, + false, + null, + ColorUtil.dyeArgb(baseColor, 0), + null, + null, + null, + pats, + List.of(), + null, + null); + } catch (Throwable ignored) { + return null; // API drift: plain shield + } + } + private static BlockEntityState copy(BlockEntityState s, String skinUrl, List patterns) { return new BlockEntityState( s.kind(), diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java index 4d61518..975dbff 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java @@ -77,7 +77,8 @@ public final class DecorationSnapshotBuilder { map, frame.getRotation().ordinal(), glow, - !frame.isVisible()); + !frame.isVisible(), + map != null ? MapPixels.markers(stack) : List.of()); } return null; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java index 8b06c04..8cbb412 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java @@ -33,6 +33,9 @@ public final class ItemSpecs { // Head skins / banner patterns live on the stack, not the material (only those kinds pay for the meta). if (be != null && ("player".equals(be.headType()) || be.kind() == BlockEntityState.Kind.BANNER)) { be = BlockEntitySnapshotBuilder.withItemMeta(be, stack.getItemMeta()); + } else if (m == Material.SHIELD) { + // Shield banner patterns + base colour (ShieldSpecialRenderer), carried as a BANNER state. + be = BlockEntitySnapshotBuilder.shieldPatterns(stack.getItemMeta()); } return new ItemSpec(m.getKey().getKey(), b.data, be, glint); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java index 7076c1e..cc7a7a1 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java @@ -1,9 +1,13 @@ package eu.mhsl.minecraft.pixelpics.render.snapshot; +import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState; import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette; import eu.mhsl.minecraft.pixelpics.utils.MapManager; import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.MapMeta; import org.bukkit.map.MapRenderer; @@ -59,17 +63,65 @@ public final class MapPixels { return b; } - private static volatile Field worldMapField, colorsField; + /** + * The map's decorations an item frame shows (MapRenderer with showOnlyFrame: MapDecorationType.showOnItemFrame + * — banners, frame markers, structure/target markers; never players), in the map data's order. Only for maps + * drawn by the vanilla renderer (CraftMapRenderer sends {@code MapItemSavedData.decorations} as cursors): + * PixelPics photo maps and other plugin-rendered maps get none. + */ + public static List markers(ItemStack stack) { + try { + if (!(stack.getItemMeta() instanceof MapMeta meta) || !meta.hasMapView()) return List.of(); + MapView view = meta.getMapView(); + if (view == null || MapManager.isManaged(view.getId())) return List.of(); + boolean vanilla = false; + for (MapRenderer r : view.getRenderers()) { + if (r instanceof ImageMapRenderer) return List.of(); + if (r.getClass().getSimpleName().equals("CraftMapRenderer")) vanilla = true; + } + if (!vanilla) return List.of(); + Object data = worldMap(view); + if (data == null) return List.of(); + Field f = decorationsField; + if (f == null || f.getDeclaringClass() != data.getClass()) { + f = data.getClass().getDeclaredField("decorations"); + f.setAccessible(true); + decorationsField = f; + } + if (!(f.get(data) instanceof Map decorations) || decorations.isEmpty()) return List.of(); + List out = new ArrayList<>(); + for (Object d : decorations.values()) { + Class c = d.getClass(); + if (!(Boolean) c.getMethod("renderOnFrame").invoke(d)) continue; + String sprite = String.valueOf(c.getMethod("getSpriteLocation").invoke(d)); + String asset = sprite.substring(sprite.indexOf(':') + 1); + out.add(new DecorationState.MapMarker( + asset, + (Byte) c.getMethod("x").invoke(d), + (Byte) c.getMethod("y").invoke(d), + (Byte) c.getMethod("rot").invoke(d))); + } + return out; + } catch (Throwable t) { + return List.of(); + } + } - /** CraftMapView#worldMap → MapItemSavedData#colors (what the vanilla CraftMapRenderer sends). */ - private static byte[] worldMapColors(MapView view) throws ReflectiveOperationException { + private static volatile Field worldMapField, colorsField, decorationsField; + + private static Object worldMap(MapView view) throws ReflectiveOperationException { Field wm = worldMapField; if (wm == null || wm.getDeclaringClass() != view.getClass()) { wm = view.getClass().getDeclaredField("worldMap"); wm.setAccessible(true); worldMapField = wm; } - Object data = wm.get(view); + return wm.get(view); + } + + /** CraftMapView#worldMap → MapItemSavedData#colors (what the vanilla CraftMapRenderer sends). */ + private static byte[] worldMapColors(MapView view) throws ReflectiveOperationException { + Object data = worldMap(view); if (data == null) return null; Field c = colorsField; if (c == null || c.getDeclaringClass() != data.getClass()) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java index 55c27ec..875a7af 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java @@ -70,7 +70,8 @@ public final class ObjectSnapshotBuilder { } if (d instanceof ItemDisplay id) { String ctx = id.getItemDisplayTransform().name().toLowerCase(java.util.Locale.ROOT); - return item(base, id.getItemStack(), ctx, false); + // ItemDisplayRenderer.submitInner turns the item by 180° about Y before submitting it. + return item(base.mul(Affine.rotY(Math.PI)), id.getItemStack(), ctx, false); } if (d instanceof TextDisplay td) { return text(base, td); @@ -84,8 +85,10 @@ public final class ObjectSnapshotBuilder { return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData()); } if (e instanceof Item it) { - // Dropped item: vanilla GROUND display transform, lifted by the baker; a fixed per-entity spin. - double spin = (e.getUniqueId().getLeastSignificantBits() & 0xFFFF) / 65536.0 * 2 * Math.PI; + // Dropped item: vanilla GROUND display transform, lifted by the baker. The spin (vanilla: age/20 + + // a random bobOffset) is frozen per entity from its yaw, which ItemEntity also randomises at creation + // (so items differ like in vanilla, and a test can fix it with setRotation). + double spin = Math.toRadians(l.getYaw()); return item(at.mul(Affine.rotY(spin)), it.getItemStack(), "ground", true); } if (e instanceof Minecart mc) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/SpecialDates.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/SpecialDates.java new file mode 100644 index 0000000..b15c96c --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/SpecialDates.java @@ -0,0 +1,44 @@ +package eu.mhsl.minecraft.pixelpics.render.util; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.MonthDay; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +/** + * Vanilla {@code net.minecraft.util.SpecialDates} (decompiled 26.3): the server's local date decides the + * christmas chest textures (ChestRenderer.xmasTextures = {@code isExtendedChristmas()}, 24–26 December) and the + * {@code minecraft:local_time} item-model property (chest / trapped chest items). + * + *

Testing: the JVM system property {@value #TEST_DATE_PROPERTY} ({@code yyyy-MM-dd}, e.g. + * {@code -Dpixelpics.testDate=2026-12-25}) replaces today's date (time of day stays the current one). Never set + * it in production. + */ +public final class SpecialDates { + + public static final String TEST_DATE_PROPERTY = "pixelpics.testDate"; + + private static final List CHRISTMAS_RANGE = + List.of(MonthDay.of(12, 24), MonthDay.of(12, 25), MonthDay.of(12, 26)); + + private SpecialDates() {} + + /** The current local date-time ({@code ZonedDateTime.now()}), or the test date at the current time of day. */ + public static ZonedDateTime now() { + ZonedDateTime now = ZonedDateTime.now(); + String test = System.getProperty(TEST_DATE_PROPERTY); + if (test == null || test.isBlank()) return now; + try { + return ZonedDateTime.of(LocalDate.parse(test.trim()), LocalTime.from(now), ZoneId.systemDefault()); + } catch (RuntimeException e) { + return now; + } + } + + /** Vanilla {@code SpecialDates.isExtendedChristmas()}: today is 24, 25 or 26 December. */ + public static boolean isExtendedChristmas() { + return CHRISTMAS_RANGE.contains(MonthDay.from(now())); + } +} diff --git a/tools/BlockEntityTestRender.java b/tools/BlockEntityTestRender.java index 3e78285..87aea1d 100644 --- a/tools/BlockEntityTestRender.java +++ b/tools/BlockEntityTestRender.java @@ -244,6 +244,36 @@ public class BlockEntityTestRender { decoScenes.put("item_frame_E", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME, 0.9375, 0.0625, 0.0625, 1.0, 0.9375, 0.9375, DecorationState.Facing.EAST, null, new eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec("diamond", null, null, false), null, 0, false, false))); + // Shields (plain / red base / blue + creeper + border patterns), chests (christmas with + // -Dpixelpics.testDate=2026-12-25) and a decorated map in frames (map/decorations sprites as MapRenderer). + BlockEntityState shieldPats = new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, false, null, 0xFF3C44AA, null, null, null, + List.of(new BannerPattern("creeper", 0xFFFED83D), new BannerPattern("border", 0xFFF9FFFE)), List.of(), null, null); + BlockEntityState shieldRed = new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, false, null, 0xFFB02E26, null, null, null, + List.of(), List.of(), null, null); + String[] frameItems = {"shield", "shield_red", "shield_patterns", "chest", "trapped_chest", "ender_chest"}; + for (String id : frameItems) { + ItemSpec spec = switch (id) { + case "shield_red" -> new ItemSpec("shield", null, shieldRed, false); + case "shield_patterns" -> new ItemSpec("shield", null, shieldPats, false); + default -> new ItemSpec(id, null, null, false); + }; + decoScenes.put("item_frame_" + id, List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME, + 0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, spec, null, 0, false, false))); + } + int[][] mapPx = new int[128][128]; + for (int y = 0; y < 128; y++) for (int x = 0; x < 128; x++) mapPx[y][x] = ((x / 16 + y / 16) & 1) == 0 ? 0xFF7FB238 : 0xFF3F76E4; + List markers = List.of( + new DecorationState.MapMarker("frame", 0, 0, 0), + new DecorationState.MapMarker("red_banner", -60, -50, 8), + new DecorationState.MapMarker("target_x", 70, 60, 0), + new DecorationState.MapMarker("frame", 40, -90, 4), + new DecorationState.MapMarker("blue_banner", -100, 90, 0)); + for (int rot : new int[] {0, 1}) { + decoScenes.put("item_frame_map_markers_r" + rot, List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME, + 0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, + new ItemSpec("filled_map", null, null, false), mapPx, rot, false, false, markers))); + } + List cells = new ArrayList<>(); List names = new ArrayList<>(scenes.keySet()); for (String name : names) { diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index 6f3f486..66b064d 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -198,6 +198,160 @@ public class PPTest extends JavaPlugin { scenes.put("be_contents_close", new Scene("be_contents_close", this::beContents, 1.2, 1.8, 2.2, 1.5, 0.6, 0)); scenes.put("be_contents_beam", new Scene("be_contents_beam", this::beContents, 4, 6, 26, 10, 9, 6)); scenes.put("be_contents_portal", new Scene("be_contents_portal", this::beContents, 11.5, 3.6, -1.8, 11.5, 0, 1.2)); + // Items in detail (decompiled 26.3 special renderers): shields (plain, base colour, banner patterns), chests, + // spawner/vault held by armor stands (+ a baby zombie: baby hand offset), item displays (fixed), item frames + // (rotation 0/1) and dropped (ground: lift = 0.1625 - model min y). items_inverted: spawner, trial spawner and + // vault as block, block display, item display and dropped item (inverted inner faces). xmas_chests: chests, + // double/trapped/ender/copper chests + chest items; render it once normally and once with the server + // started with -Dpixelpics.testDate=2026-12-25 (christmas textures for chests and trapped chests). + // frames_markers: map decorations on framed maps (banner markers via toggleBanner, frame/target markers, + // an invisible-on-frames player marker; rotations 0/1/3), a PixelPics photo map and an undecorated map. + scenes.put("items_detail", new Scene("items_detail", this::itemsDetail, 4.5, 3.2, 13, 4.5, 0.9, 2)); + scenes.put("items_detail_close", new Scene("items_detail_close", this::itemsDetail, 2.2, 1.7, 7.4, 2.2, 0.9, 2)); + scenes.put("items_detail_side", new Scene("items_detail_side", this::itemsDetail, -4.5, 2.4, 4.5, 5, 0.9, 3)); + scenes.put("items_inverted", new Scene("items_inverted", this::itemsInverted, 3, 2.4, 7, 3, 0.6, 1.5)); + scenes.put("xmas_chests", new Scene("xmas_chests", this::xmasChests, 6, 3.6, 9.5, 6, 0.5, 1.5)); + scenes.put("frames_markers", new Scene("frames_markers", this::framesMarkers, 2.5, 2.4, 4.2, 2.5, 2.4, 0)); + } + + private static ItemStack shield(DyeColor base, Object... patterns) { + ItemStack s = it(Material.SHIELD); + org.bukkit.inventory.meta.ShieldMeta m = (org.bukkit.inventory.meta.ShieldMeta) s.getItemMeta(); + m.setBaseColor(base); + for (int i = 0; i + 1 < patterns.length; i += 2) + m.addPattern(new org.bukkit.block.banner.Pattern((DyeColor) patterns[i], (org.bukkit.block.banner.PatternType) patterns[i + 1])); + s.setItemMeta(m); + return s; + } + + private void itemsDetail(Location o) { + for (int x = -1; x <= 11; x++) for (int y = 0; y <= 3; y++) put(o, x, y, -1, "stone"); + ItemStack[] items = {it(Material.SHIELD), shield(DyeColor.RED), + shield(DyeColor.BLUE, DyeColor.YELLOW, org.bukkit.block.banner.PatternType.CREEPER, + DyeColor.WHITE, org.bukkit.block.banner.PatternType.BORDER), + it(Material.CHEST), it(Material.TRAPPED_CHEST), it(Material.ENDER_CHEST), it(Material.COPPER_CHEST), + it(Material.SPAWNER), it(Material.VAULT), it(Material.RED_BANNER)}; + for (int i = 0; i < items.length; i++) { + frame(o, i, 1, 0, BlockFace.SOUTH, false, items[i], 0, true); + frame(o, i, 2, 0, BlockFace.SOUTH, false, items[i], 1, true); + ArmorStand as = spawn(o, i + 0.5, 0, 2.5, 0, ArmorStand.class); + as.setArms(true); + as.getEquipment().setItemInMainHand(items[i]); + if (i < 3) as.getEquipment().setItemInOffHand(items[i]); + ItemDisplay d = spawn(o, i + 0.5, 0.6, 4.5, 0, ItemDisplay.class); + d.setItemStack(items[i]); + d.setItemDisplayTransform(ItemDisplay.ItemDisplayTransform.FIXED); + d.setTransformation(new Transformation(new Vector3f(), new Quaternionf(), new Vector3f(0.6f), new Quaternionf())); + drop(o, i + 0.5, 0, 6.5, items[i], i * 20); + } + Zombie baby = spawn(o, 10.5, 0, 2.5, 0, Zombie.class); + baby.setBaby(); + baby.getEquipment().setItemInMainHand(it(Material.CHEST)); + baby.getEquipment().setItemInOffHand(items[2]); + Zombie z = spawn(o, 11.5, 0, 4, 0, Zombie.class); + z.setAdult(); + z.getEquipment().setItemInMainHand(it(Material.TRAPPED_CHEST)); + z.getEquipment().setItemInOffHand(items[2]); + } + + private void itemsInverted(Location o) { + String[] blocks = {"spawner", "trial_spawner", "vault[facing=south]"}; + Material[] mats = {Material.SPAWNER, Material.TRIAL_SPAWNER, Material.VAULT}; + for (int i = 0; i < blocks.length; i++) { + put(o, i * 2, 0, 0, blocks[i]); + BlockDisplay b = spawn(o, i * 2, 0, 2, 0, BlockDisplay.class); + b.setBlock(Bukkit.createBlockData("minecraft:" + blocks[i])); + ItemDisplay d = spawn(o, i * 2 + 0.5, 0.5, 4.5, 0, ItemDisplay.class); + d.setItemStack(it(mats[i])); + drop(o, i * 2 + 0.5, 0, 6, it(mats[i]), 15); + } + } + + private void xmasChests(Location o) { + put(o, 0, 0, 0, "chest[facing=south]"); + put(o, 3, 0, 0, "chest[facing=south,type=left]"); + put(o, 2, 0, 0, "chest[facing=south,type=right]"); + put(o, 5, 0, 0, "trapped_chest[facing=south]"); + put(o, 8, 0, 0, "trapped_chest[facing=south,type=left]"); + put(o, 7, 0, 0, "trapped_chest[facing=south,type=right]"); + put(o, 10, 0, 0, "ender_chest[facing=south]"); + put(o, 12, 0, 0, "copper_chest[facing=south]"); + Material[] mats = {Material.CHEST, Material.TRAPPED_CHEST}; + for (int i = 0; i < 2; i++) { + ItemDisplay d = spawn(o, 0.5 + i * 1.5, 0.6, 3.5, 0, ItemDisplay.class); + d.setItemStack(it(mats[i])); + d.setItemDisplayTransform(ItemDisplay.ItemDisplayTransform.FIXED); + ArmorStand as = spawn(o, 4 + i * 1.5, 0, 3.5, 0, ArmorStand.class); + as.setArms(true); + as.getEquipment().setItemInMainHand(it(mats[i])); + drop(o, 7.5 + i, 0, 3.5, it(mats[i]), 30); + put(o, 10 + i, 0, 3, "stone"); + frame(o, 10 + i, 1, 3, BlockFace.UP, false, it(mats[i]), 0, true); + } + } + + private void framesMarkers(Location o) { + for (int x = -2; x <= 7; x++) for (int y = 0; y <= 4; y++) put(o, x, y, -1, "stone"); + ItemStack marked = vanillaMap(); + org.bukkit.map.MapView v = ((org.bukkit.inventory.meta.MapMeta) marked.getItemMeta()).getMapView(); + v.setScale(org.bukkit.map.MapView.Scale.CLOSEST); + v.setCenterX(o.getBlockX()); + v.setCenterZ(o.getBlockZ()); + put(o, -5, 0, -10, "red_banner[rotation=0]"); + put(o, 16, 0, -11, "lime_banner[rotation=4]"); + mapBanner(v, o.clone().add(-5, 0, -10)); + mapBanner(v, o.clone().add(16, 0, -11)); + ItemFrame f0 = frame(o, 0, 2, 0, BlockFace.SOUTH, false, marked, 0, true); + frame(o, 1, 2, 0, BlockFace.SOUTH, false, marked, 1, true); + frame(o, 2, 2, 0, BlockFace.SOUTH, false, synthMap(), 0, true); + frame(o, 3, 2, 0, BlockFace.SOUTH, false, vanillaMap(), 0, true); + frame(o, 4, 2, 0, BlockFace.SOUTH, true, marked, 3, true); + // Frame markers like MapItemSavedData.tickCarriedBy (key frame-, frame block pos, rot = 2D facing · 90): + // the real frame, plus fictitious ones turned east/north; a red X and a player marker (not on frames). + mapDecoration(v, "FRAME", "frame-" + f0.getEntityId(), o.getBlockX(), o.getBlockZ(), 0); + mapDecoration(v, "FRAME", "frame-pptest-1", o.getBlockX() + 30, o.getBlockZ() - 25, 270); + mapDecoration(v, "FRAME", "frame-pptest-2", o.getBlockX() - 28, o.getBlockZ() + 30, 180); + mapDecoration(v, "RED_X", "pptest-x", o.getBlockX() + 40, o.getBlockZ() + 40, 180); + mapDecoration(v, "PLAYER", "pptest-player", o.getBlockX() + 10, o.getBlockZ() + 10, 0); + } + + /** MapItemSavedData.toggleBanner(level, pos) on the map's server data (reflective: no API). */ + private void mapBanner(org.bukkit.map.MapView v, Location banner) { + try { + Object data = mapData(v); + Object level = w.getClass().getMethod("getHandle").invoke(w); + Class bp = Class.forName("net.minecraft.core.BlockPos", true, data.getClass().getClassLoader()); + Object pos = bp.getConstructor(int.class, int.class, int.class) + .newInstance(banner.getBlockX(), banner.getBlockY(), banner.getBlockZ()); + for (java.lang.reflect.Method m : data.getClass().getMethods()) + if (m.getName().equals("toggleBanner") && m.getParameterCount() == 2) + getLogger().info("toggleBanner " + banner.toVector() + " -> " + m.invoke(data, level, pos)); + } catch (ReflectiveOperationException e) { + getLogger().warning("mapBanner: " + e); + } + } + + /** MapItemSavedData.addDecoration(MapDecorationTypes., level, key, x, z, yRot, null) (private, reflective). */ + private void mapDecoration(org.bukkit.map.MapView v, String type, String key, double x, double z, double rot) { + try { + Object data = mapData(v); + Object level = w.getClass().getMethod("getHandle").invoke(w); + Object holder = Class.forName("net.minecraft.world.level.saveddata.maps.MapDecorationTypes", true, + data.getClass().getClassLoader()).getField(type).get(null); + for (java.lang.reflect.Method m : data.getClass().getDeclaredMethods()) + if (m.getName().equals("addDecoration") && m.getParameterCount() == 7) { + m.setAccessible(true); + m.invoke(data, holder, level, key, x, z, rot, null); + } + } catch (ReflectiveOperationException e) { + getLogger().warning("mapDecoration: " + e); + } + } + + private static Object mapData(org.bukkit.map.MapView v) throws ReflectiveOperationException { + java.lang.reflect.Field wm = v.getClass().getDeclaredField("worldMap"); + wm.setAccessible(true); + return wm.get(v); } private void beContents(Location o) { @@ -212,6 +366,7 @@ public class PPTest extends JavaPlugin { c.setItem(i, it(food[i])); c.setCookTimeTotal(i, 1_000_000); // else the lit campfire pops them out at once (total 0) } + c.stopCooking(); // keep the food raw and in place while the scene waits c.update(true, false); } if (o.clone().add(4, 0, 0).getBlock().getState() instanceof org.bukkit.block.Campfire c) { @@ -561,7 +716,9 @@ public class PPTest extends JavaPlugin { put(o, 8, 0, 1, "water"); Mannequin m3 = rfSpawn(o, 8.5, 0.1, 0.5, 180, Mannequin.class); m3.setPose(Pose.SWIMMING, true); + m3.setGravity(false); // else it floats up in the water while the scene waits (non-deterministic) Mannequin m4 = rfSpawn(o, 10, 1.0, 0, 180, Mannequin.class); + m4.setGravity(false); // keeps gliding at y 1 instead of falling m4.getEquipment().setChestplate(it(Material.ELYTRA)); m4.setPose(Pose.FALL_FLYING, true); Mannequin m5 = rfSpawn(o, 12, 0, 0, 180, Mannequin.class); @@ -649,7 +806,30 @@ public class PPTest extends JavaPlugin { return true; } + /** + * Loads the scene's chunks and waits until they are entity-ticking before building: entities spawned into a + * chunk that only becomes entity-ticking a few (variable) ticks later start moving late (projectile scenes were + * not reproducible). + */ private void run(Scene sc, Location o) { + loadArea(o); + whenEntityTicking(sc, o, 0); + } + + private void whenEntityTicking(Scene sc, Location o, int waited) { + boolean ready = true; + for (int cx = -1; cx <= 2; cx++) + for (int cz = -2; cz <= 2; cz++) + ready &= w.getChunkAt((o.getBlockX() >> 4) + cx, (o.getBlockZ() >> 4) + cz).getLoadLevel() == Chunk.LoadLevel.ENTITY_TICKING; + if (!ready && waited < 200) { + Bukkit.getScheduler().runTaskLater(this, () -> whenEntityTicking(sc, o, waited + 1), 1); + return; + } + if (!ready) getLogger().warning("scene " + sc.name + ": chunks not entity-ticking after 200 ticks"); + build(sc, o); + } + + private void build(Scene sc, Location o) { try { prepareArea(o); sc.build.accept(o.clone()); @@ -666,7 +846,7 @@ public class PPTest extends JavaPlugin { Location eye = o.clone().add(sc.camX, sc.camY, sc.camZ); Vector look = o.clone().add(sc.lookX, sc.lookY, sc.lookZ).toVector().subtract(eye.toVector()); eye.setDirection(look); - RenderJob job = r.prepare(eye, sc.name.endsWith("_sq") ? new Resolution(384, 384) : sc.name.startsWith("proj_") || sc.name.startsWith("rf_") || sc.name.startsWith("pose_") || sc.name.startsWith("frames") || sc.name.startsWith("geo_") ? new Resolution(960, 640) : new Resolution(480, 320), null); + RenderJob job = r.prepare(eye, sc.name.endsWith("_sq") ? new Resolution(384, 384) : sc.name.startsWith("proj_") || sc.name.startsWith("rf_") || sc.name.startsWith("pose_") || sc.name.startsWith("frames") || sc.name.startsWith("geo_") || sc.name.startsWith("items_") || sc.name.startsWith("xmas_") ? new Resolution(960, 640) : new Resolution(480, 320), null); if (sc.name.equals("dbg")) { try { java.lang.reflect.Field f = DefaultScreenRenderer.class.getDeclaredField("objectBaker"); @@ -730,43 +910,74 @@ public class PPTest extends JavaPlugin { }); } - /** Clears the area (entities + blocks) and lays a 14x14 stone floor at the origin's y-1. */ - private void prepareArea(Location o) { + /** Loads the scene's chunks and keeps them loaded (plugin chunk tickets). */ + private void loadArea(Location o) { for (int cx = -1; cx <= 2; cx++) for (int cz = -2; cz <= 2; cz++) { Chunk c = w.getChunkAt((o.getBlockX() >> 4) + cx, (o.getBlockZ() >> 4) + cz); c.load(true); c.addPluginChunkTicket(this); } - for (Entity e : w.getNearbyEntities(o.clone().add(6, 5, 2), 20, 20, 20)) if (!(e instanceof Player)) e.remove(); + } + + /** Clears the area (blocks + entities) and lays a 24x24 stone floor at the origin's y-1. */ + private void prepareArea(Location o) { + // Blocks first: removing a campfire/shelf/container drops its items, which the entity sweep then removes + // (before, the previous run's cooked campfire food lay around at random). for (int x = -6; x < 18; x++) for (int z = -12; z < 12; z++) for (int y = -1; y < 12; y++) w.getBlockAt(o.getBlockX() + x, o.getBlockY() + y, o.getBlockZ() + z) .setType(y == -1 ? Material.SMOOTH_STONE : Material.AIR, false); + for (Entity e : w.getNearbyEntities(o.clone().add(6, 5, 2), 20, 20, 20)) if (!(e instanceof Player)) e.remove(); } // --- helpers --------------------------------------------------------------------------------- + /** + * Deterministic spawn (pixel-diffable between plugin builds): randomizeData=false (no finalizeSpawn — no random + * variants, sizes, babies/jockeys or equipment) and the body turned to {@code yaw} (spawned mobs otherwise keep a + * random body yaw). Scenes set every variant/equipment they show explicitly. + */ private T spawn(Location o, double x, double y, double z, float yaw, Class type) { - Location l = o.clone().add(x, y, z); - l.setYaw(yaw); - T e = w.spawn(l, type, en -> { - if (en instanceof Mob m) m.setAI(false); - en.setPersistent(false); - }); - if (e instanceof LivingEntity le) le.setRemoveWhenFarAway(false); + T e = rfSpawn(o, x, y, z, yaw, type); + if (e instanceof LivingEntity le) le.setBodyYaw(yaw); return e; } private Entity spawn(Location o, double x, double y, double z, float yaw, EntityType type) { - Location l = o.clone().add(x, y, z); - l.setYaw(yaw); - Entity e = w.spawnEntity(l, type); - if (e instanceof Mob m) m.setAI(false); + Entity e = rfSpawn(o, x, y, z, yaw, type); + e.setPersistent(false); + if (e instanceof LivingEntity le) { + le.setRemoveWhenFarAway(false); + le.setBodyYaw(yaw); + } return e; } + /** + * Respawns until the entity id is even: a full boat turns an animal passenger by 90° or 270° depending on the + * id's parity (AbstractBoat.positionRider), and ids depend on the server's history. + */ + private T evenId(java.util.function.Supplier spawner) { + T e = spawner.get(); + while (e.getEntityId() % 2 != 0) { + e.remove(); + e = spawner.get(); + } + return e; + } + + /** A dropped item at rest: no velocity/gravity, fixed yaw (PixelPics freezes the item spin from the yaw). */ + private Item drop(Location o, double x, double y, double z, ItemStack stack, float yaw) { + return w.dropItem(o.clone().add(x, y, z), stack, i -> { + i.setVelocity(new Vector()); + i.setGravity(false); + i.setRotation(yaw, 0); + i.setPersistent(false); + }); + } + private static ItemStack it(Material m) { return new ItemStack(m); } @@ -839,7 +1050,7 @@ public class PPTest extends JavaPlugin { mc.addPassenger(spawn(o, 7, 1, 0, 180, Villager.class)); Boat b = (Boat) spawn(o, 9.5, 0, 0, 180, EntityType.OAK_BOAT); b.addPassenger(spawn(o, 9.5, 1, 0, 180, Zombie.class)); - b.addPassenger(spawn(o, 9.5, 1, 0, 180, Pig.class)); + b.addPassenger(evenId(() -> spawn(o, 9.5, 1, 0, 180, Pig.class))); Camel c = spawn(o, 2, 0, 3.5, 90, Camel.class); c.getInventory().setSaddle(it(Material.SADDLE)); c.addPassenger(spawn(o, 2, 2, 3.5, 90, EntityType.MANNEQUIN)); @@ -955,10 +1166,10 @@ public class PPTest extends JavaPlugin { } private void misc(Location o) { - w.dropItem(o.clone().add(0, 0.2, 0), it(Material.DIAMOND_SWORD), i -> i.setVelocity(new Vector())); - w.dropItem(o.clone().add(1, 0.2, 0), it(Material.OAK_LOG), i -> i.setVelocity(new Vector())); - w.dropItem(o.clone().add(2, 0.2, 0), new ItemStack(Material.COBBLESTONE, 32), i -> i.setVelocity(new Vector())); - w.dropItem(o.clone().add(3, 0.2, 0), it(Material.APPLE), i -> i.setVelocity(new Vector())); + drop(o, 0, 0, 0, it(Material.DIAMOND_SWORD), 30); + drop(o, 1, 0, 0, it(Material.OAK_LOG), 60); + drop(o, 2, 0, 0, new ItemStack(Material.COBBLESTONE, 32), 0); + drop(o, 3, 0, 0, it(Material.APPLE), 90); FallingBlock fb = w.spawn(o.clone().add(4.5, 0.5, 0.5), FallingBlock.class, f -> f.setGravity(false)); fb.setBlockData(Bukkit.createBlockData("minecraft:sand")); TNTPrimed tnt = w.spawn(o.clone().add(6, 0, 0.5), TNTPrimed.class);