From 15012d99289d92b29d1efa5b5de7288241ee33ed Mon Sep 17 00:00:00 2001 From: weikengchen Date: Sun, 29 Mar 2026 12:16:13 +0800 Subject: [PATCH 01/12] update mod to Minecraft 26.1 - Update minecraft version, fabric API, iris, loom, and gradle wrapper - Migrate to fabric-loom plugin (from fabric-loom-remap), drop mojang mappings - Update dependency configurations (modImplementation -> implementation) - Update networking API (playS2C -> clientboundPlay, playC2S -> serverboundPlay) - Update access widener namespace (named -> official) - Update GuiGraphics -> GuiGraphicsExtractor rendering API - Replace entityCutoutNoCull with entityCutout Co-Authored-By: Claude Opus 4.6 (1M context) --- build.gradle | 9 ++-- gradle.properties | 10 ++--- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 0 .../net/imaginefun/gui/ImagineFunButton.java | 44 +++++++------------ .../playerheads/PlayerHeadRenderer.java | 2 +- .../java/net/imaginefun/ImagineFunUtils.java | 6 +-- src/main/resources/fabric.mod.json | 2 +- .../resources/imaginefunutils.accesswidener | 2 +- .../resourcepacks/imaginefunutils/pack.mcmeta | 2 +- 10 files changed, 33 insertions(+), 46 deletions(-) mode change 100644 => 100755 gradlew diff --git a/build.gradle b/build.gradle index d5e412e..7def1d6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" + id 'net.fabricmc.fabric-loom' version "${loom_version}" id 'maven-publish' } @@ -38,13 +38,12 @@ loom { dependencies { // To change the versions see the gradle.properties file minecraft "com.mojang:minecraft:${project.minecraft_version}" - mappings loom.officialMojangMappings() - modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + implementation "net.fabricmc:fabric-loader:${project.loader_version}" // Fabric API. This is technically optional, but you probably want it anyway. - modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" - modCompileOnly "maven.modrinth:iris:${project.iris_version}" + compileOnly "maven.modrinth:iris:${project.iris_version}" } processResources { diff --git a/gradle.properties b/gradle.properties index d2ddfd9..28ec650 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,15 +7,15 @@ org.gradle.configuration-cache=false # Fabric Properties # check these on https://fabricmc.net/develop -minecraft_version=1.21.11 +minecraft_version=26.1 loader_version=0.18.4 -loom_version=1.14-SNAPSHOT +loom_version=1.15-SNAPSHOT # Mod Properties -mod_version=0.0.7 +mod_version=0.0.8 maven_group=net.imaginefun archives_base_name=ImagineFunUtils # Dependencies -fabric_version=0.140.2+1.21.11 -iris_version=1.10.5+1.21.11-fabric +fabric_version=0.144.3+26.1 +iris_version=1.10.8+26.1-fabric diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2..dbc3ce4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/client/java/net/imaginefun/gui/ImagineFunButton.java b/src/client/java/net/imaginefun/gui/ImagineFunButton.java index 72dab09..9c851e9 100644 --- a/src/client/java/net/imaginefun/gui/ImagineFunButton.java +++ b/src/client/java/net/imaginefun/gui/ImagineFunButton.java @@ -1,22 +1,17 @@ package net.imaginefun.gui; import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ActiveTextCollector; import net.minecraft.client.gui.Font; -import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.WidgetSprites; -import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; public class ImagineFunButton extends Button { - private static final WidgetSprites SPRITES = new WidgetSprites( - Identifier.withDefaultNamespace("widget/button"), - Identifier.withDefaultNamespace("widget/button_disabled"), - Identifier.withDefaultNamespace("widget/button_highlighted") - ); - private static final int COLOR_FROM = 0xFFee609c; private static final int COLOR_TO = 0xFF4f88de; @@ -25,36 +20,29 @@ public ImagineFunButton(int x, int y, int width, int height, Component message, } @Override - protected void renderContents(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - if (this.alpha <= 0.0f) { - return; - } - Identifier sprite = SPRITES.get(this.active, this.isHovered()); - graphics.blitSprite(RenderPipelines.GUI_TEXTURED, sprite, this.getX(), this.getY(), this.width, this.height); + protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + this.extractDefaultSprite(graphics); - Minecraft minecraft = Minecraft.getInstance(); - Font font = minecraft.font; + Font font = Minecraft.getInstance().font; String text = this.getMessage().getString(); - int textWidth = font.width(text); - int textHeight = font.lineHeight; - float startX = this.getX() + (this.width - textWidth) / 2.0f; - float startY = this.getY() + (this.height - textHeight) / 2.0f; - - float currentX = startX; + MutableComponent gradientText = Component.empty(); + float currentWidth = 0; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); String charStr = String.valueOf(c); int charWidth = font.width(charStr); - float gradientPos = (currentX - startX + charWidth / 2.0f) / textWidth; + float gradientPos = textWidth > 0 ? (currentWidth + charWidth / 2.0f) / textWidth : 0; int color = interpolateColor(COLOR_FROM, COLOR_TO, gradientPos); - graphics.drawString(font, charStr, (int) currentX, (int) startY, color, true); - - currentX += charWidth; + gradientText.append(Component.literal(charStr).withStyle(Style.EMPTY.withColor(TextColor.fromRgb(color & 0xFFFFFF)))); + currentWidth += charWidth; } + + ActiveTextCollector textCollector = graphics.textRendererForWidget(this, GuiGraphicsExtractor.HoveredTextEffects.NONE); + this.extractScrollingStringOverContents(textCollector, gradientText, 2); } private int interpolateColor(int from, int to, float t) { diff --git a/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java b/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java index 2d5468c..7880193 100644 --- a/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java +++ b/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java @@ -184,7 +184,7 @@ public static void renderCustomSkull( float z = 0.0f; MultiBufferSource.BufferSource bufferSource = Minecraft.getInstance().renderBuffers().bufferSource(); - VertexConsumer vertexConsumer = bufferSource.getBuffer(RenderTypes.entityCutoutNoCull(actualIdentifier)); + VertexConsumer vertexConsumer = bufferSource.getBuffer(RenderTypes.entityCutout(actualIdentifier)); Matrix4f matrix4f = matrixStack.last().pose(); Pose pose = matrixStack.last(); diff --git a/src/main/java/net/imaginefun/ImagineFunUtils.java b/src/main/java/net/imaginefun/ImagineFunUtils.java index ea8c443..ece382a 100644 --- a/src/main/java/net/imaginefun/ImagineFunUtils.java +++ b/src/main/java/net/imaginefun/ImagineFunUtils.java @@ -20,9 +20,9 @@ public class ImagineFunUtils implements ModInitializer { @Override public void onInitialize() { - PayloadTypeRegistry.playS2C().register(GameTestAddMarkerPayload.TYPE, GameTestAddMarkerPayload.STREAM_CODEC); - PayloadTypeRegistry.playS2C().register(PlayerForceLookPayload.TYPE, PlayerForceLookPayload.STREAM_CODEC); - PayloadTypeRegistry.playC2S().register(HandshakePayload.TYPE, HandshakePayload.STREAM_CODEC); + PayloadTypeRegistry.clientboundPlay().register(GameTestAddMarkerPayload.TYPE, GameTestAddMarkerPayload.STREAM_CODEC); + PayloadTypeRegistry.clientboundPlay().register(PlayerForceLookPayload.TYPE, PlayerForceLookPayload.STREAM_CODEC); + PayloadTypeRegistry.serverboundPlay().register(HandshakePayload.TYPE, HandshakePayload.STREAM_CODEC); FabricLoader.getInstance().getModContainer(MOD_ID).ifPresent(mod -> ResourceLoader.registerBuiltinPack( diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index f5fcf46..38f21b5 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -31,7 +31,7 @@ ], "depends": { "fabricloader": ">=0.18.3", - "minecraft": "~1.21.11", + "minecraft": "~26.1", "java": ">=21", "fabric-api": "*" } diff --git a/src/main/resources/imaginefunutils.accesswidener b/src/main/resources/imaginefunutils.accesswidener index 4ddf8ab..0b481ad 100644 --- a/src/main/resources/imaginefunutils.accesswidener +++ b/src/main/resources/imaginefunutils.accesswidener @@ -1,4 +1,4 @@ -accessWidener v2 named +accessWidener v2 official accessible class net/minecraft/client/renderer/debug/GameTestBlockHighlightRenderer$Marker accessible class net/minecraft/client/renderer/rendertype/RenderSetup$TextureBinding diff --git a/src/main/resources/resourcepacks/imaginefunutils/pack.mcmeta b/src/main/resources/resourcepacks/imaginefunutils/pack.mcmeta index 3e2d0d2..df59131 100644 --- a/src/main/resources/resourcepacks/imaginefunutils/pack.mcmeta +++ b/src/main/resources/resourcepacks/imaginefunutils/pack.mcmeta @@ -1,6 +1,6 @@ { "pack": { - "pack_format": 88, + "pack_format": 84, "min_format": 46, "max_format": 1995, "supported_formats": { From 1aa0add74a3564146cfc8300598dd35c2941346c Mon Sep 17 00:00:00 2001 From: weikengchen Date: Sun, 29 Mar 2026 12:41:05 +0800 Subject: [PATCH 02/12] update mod to Minecraft 26.1 - Update minecraft, fabric API, iris, loom, and gradle wrapper versions - Migrate to fabric-loom plugin (from fabric-loom-remap), drop mojang mappings - Update dependency configurations (modImplementation -> implementation) - Update networking API (playS2C -> clientboundPlay, playC2S -> serverboundPlay) - Update access widener namespace (named -> official) - Update GuiGraphics -> GuiGraphicsExtractor rendering API - Replace entityCutoutNoCull with entityCutout - Retarget SkullBlockRendererMixin to submit() (submitSkull signature changed) - Update PlayerHeadRenderer to use pre-applied transformations Co-Authored-By: Claude Opus 4.6 (1M context) --- gradle.properties | 2 +- .../client/SkullBlockRendererMixin.java | 44 ++++++++----------- .../playerheads/PlayerHeadRenderer.java | 21 +++------ 3 files changed, 25 insertions(+), 42 deletions(-) diff --git a/gradle.properties b/gradle.properties index 28ec650..8bfd4d2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ loader_version=0.18.4 loom_version=1.15-SNAPSHOT # Mod Properties -mod_version=0.0.8 +mod_version=0.0.7 maven_group=net.imaginefun archives_base_name=ImagineFunUtils diff --git a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java index f6f73a2..4fc7ccd 100644 --- a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java +++ b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java @@ -13,16 +13,13 @@ import com.mojang.blaze3d.vertex.PoseStack; -import net.minecraft.client.model.object.skull.SkullModelBase; import net.minecraft.client.renderer.SubmitNodeCollector; import net.minecraft.client.renderer.blockentity.SkullBlockRenderer; -import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.blockentity.state.SkullBlockRenderState; import net.minecraft.client.renderer.rendertype.RenderSetup; -import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.state.level.CameraRenderState; import net.minecraft.resources.Identifier; -import net.minecraft.core.Direction; - @Mixin(SkullBlockRenderer.class) public abstract class SkullBlockRendererMixin { @Unique @@ -35,31 +32,25 @@ private static Identifier getTextureLocation(RenderSetup renderSetup, String tex return ((TextureBindingAccessor) textureBinding).invokeLocation(); } - + @Inject( - method = "submitSkull(Lnet/minecraft/core/Direction;FFLcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;ILnet/minecraft/client/model/object/skull/SkullModelBase;Lnet/minecraft/client/renderer/rendertype/RenderType;ILnet/minecraft/client/renderer/feature/ModelFeatureRenderer$CrumblingOverlay;)V", + method = "submit(Lnet/minecraft/client/renderer/blockentity/state/SkullBlockRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/level/CameraRenderState;)V", at = @At("HEAD"), cancellable = true ) - private static void onStaticRender( - Direction facing, - float yaw, - float poweredTicks, - PoseStack matrices, - SubmitNodeCollector queue, - int light, - SkullModelBase model, - RenderType renderLayer, - int outlineColor, - ModelFeatureRenderer.CrumblingOverlay crumblingOverlay, + private void onSubmit( + SkullBlockRenderState state, + PoseStack poseStack, + SubmitNodeCollector submitNodeCollector, + CameraRenderState camera, CallbackInfo ci ) { - if (renderLayer == null) { + if (state.renderType == null) { return; } - RenderTypeAccessor playerSkinCacheEntryAccessor = (RenderTypeAccessor) renderLayer; - RenderSetup renderSetup = playerSkinCacheEntryAccessor.getState(); + RenderTypeAccessor renderTypeAccessor = (RenderTypeAccessor) state.renderType; + RenderSetup renderSetup = renderTypeAccessor.getState(); if (renderSetup == null) { return; } @@ -69,14 +60,17 @@ private static void onStaticRender( return; } + poseStack.pushPose(); + poseStack.mulPose(state.transformation); + boolean success = PlayerHeadRenderer.render( texture, - matrices, - facing, - light, - yaw + poseStack, + state.lightCoords ); + poseStack.popPose(); + if (success) { ci.cancel(); } diff --git a/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java b/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java index 7880193..db227ec 100644 --- a/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java +++ b/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java @@ -10,15 +10,12 @@ import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.PoseStack.Pose; import com.mojang.blaze3d.vertex.VertexConsumer; -import com.mojang.math.Axis; - import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.rendertype.RenderTypes; import net.minecraft.client.renderer.texture.AbstractTexture; import net.minecraft.client.renderer.texture.DynamicTexture; import net.minecraft.client.renderer.texture.OverlayTexture; -import net.minecraft.core.Direction; import net.minecraft.resources.Identifier; public class PlayerHeadRenderer { @@ -37,9 +34,7 @@ public class PlayerHeadRenderer { public static boolean render( Identifier skinTexture, PoseStack matrixStack, - Direction direction, - int light, - float yaw + int light ) { try { if (net.irisshaders.iris.api.v0.IrisApi.getInstance().isRenderingShadowPass()) { @@ -75,7 +70,7 @@ public static boolean render( int controlB = (controlPixel >> 0) & 0xFF; int controlA = (controlPixel >> 24) & 0xFF; - renderCustomSkull(direction, light, yaw, matrixStack, skinTexture, + renderCustomSkull(light, matrixStack, skinTexture, image, controlA, controlR, @@ -135,9 +130,7 @@ public static Identifier getNewImage(Identifier original, NativeImage input) { * @param scale The scale factor for the skull (based on alpha channel of marker pixel) */ public static void renderCustomSkull( - Direction direction, int light, - float yaw, PoseStack matrixStack, Identifier skinTexture, NativeImage image, @@ -148,13 +141,9 @@ public static void renderCustomSkull( ) { matrixStack.pushPose(); - if (direction == null) { - matrixStack.translate(0.5F, 0.0F, 0.5F); - } else { - matrixStack.translate(0.5F - direction.getStepX() * 0.25F, 0.25F, 0.5F - direction.getStepZ() * 0.25F); - } - matrixStack.scale(-1.1875F, -1.1875F, 1.1875F); - matrixStack.mulPose(Axis.YP.rotationDegrees(yaw)); + // Position/rotation already applied via state.transformation + // Apply additional scale (from -1,-1,1 to -1.1875,-1.1875,1.1875) and offset + matrixStack.scale(1.1875F, 1.1875F, 1.1875F); matrixStack.translate(0.0F, -0.211F, -0.211F); int overlay = OverlayTexture.NO_OVERLAY; From 723d21fc472c570d4805c9ba83a993e3dca84cf2 Mon Sep 17 00:00:00 2001 From: weikengchen Date: Sun, 29 Mar 2026 13:17:10 +0800 Subject: [PATCH 03/12] fix player head rendering for 26.1 In 26.1, submitSkull lost its Direction and yaw parameters. The transforms that were inside submitSkull moved to individual callers differently: - Caller 1 (block skulls): pre-applies state.transformation - Callers 2/3/4 (worn heads, items): changed or removed pre-transforms Fix by injecting into each caller separately: - submit(): extract direction/yaw from state.transformation matrix, restore the original PlayerHeadRenderer transform logic - submitSkull(): hardcode direction=null, yaw=180 (matching 1.21.11 callers 2/3/4), only fires for non-block-skull callers Also restore PlayerHeadRenderer to its original 1.21.11 interface with Direction and yaw parameters, and keep mod_version at 0.0.7. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/SkullBlockRendererMixin.java | 148 ++++++++++++++++-- .../playerheads/PlayerHeadRenderer.java | 41 ++--- 2 files changed, 158 insertions(+), 31 deletions(-) diff --git a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java index 4fc7ccd..ed0e83e 100644 --- a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java +++ b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java @@ -3,6 +3,9 @@ import java.util.Map; +import org.joml.Matrix3f; +import org.joml.Matrix4fc; +import org.joml.Quaternionf; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; @@ -13,11 +16,15 @@ import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.model.object.skull.SkullModelBase; import net.minecraft.client.renderer.SubmitNodeCollector; import net.minecraft.client.renderer.blockentity.SkullBlockRenderer; import net.minecraft.client.renderer.blockentity.state.SkullBlockRenderState; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; import net.minecraft.client.renderer.rendertype.RenderSetup; +import net.minecraft.client.renderer.rendertype.RenderType; import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.core.Direction; import net.minecraft.resources.Identifier; @Mixin(SkullBlockRenderer.class) @@ -33,6 +40,31 @@ private static Identifier getTextureLocation(RenderSetup renderSetup, String tex return ((TextureBindingAccessor) textureBinding).invokeLocation(); } + @Unique + private static Identifier getTextureFromRenderType(RenderType renderType) { + if (renderType == null) { + return null; + } + RenderTypeAccessor renderTypeAccessor = (RenderTypeAccessor) renderType; + RenderSetup renderSetup = renderTypeAccessor.getState(); + if (renderSetup == null) { + return null; + } + return getTextureLocation(renderSetup, "Sampler0"); + } + + /** + * Caller 1: SkullBlockRenderer.submit (skull blocks placed in the world). + * + * In 1.21.11, submit passed state.direction and state.rotationDegrees to submitSkull, + * which applied translate + scale(-1,-1,1) internally. In 26.1, those fields were replaced + * by state.transformation, and submitSkull no longer applies any transforms. + * + * We inject at HEAD (before mulPose(transformation)), extract direction and yaw from + * the transformation, and call PlayerHeadRenderer with the same interface as 1.21.11. + * Cancelling submit prevents submitSkull from being called, so the submitSkull mixin + * below won't double-fire. + */ @Inject( method = "submit(Lnet/minecraft/client/renderer/blockentity/state/SkullBlockRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/level/CameraRenderState;)V", at = @At("HEAD"), @@ -45,34 +77,126 @@ private void onSubmit( CameraRenderState camera, CallbackInfo ci ) { - if (state.renderType == null) { + Identifier texture = getTextureFromRenderType(state.renderType); + if (texture == null) { return; } - RenderTypeAccessor renderTypeAccessor = (RenderTypeAccessor) state.renderType; - RenderSetup renderSetup = renderTypeAccessor.getState(); - if (renderSetup == null) { - return; + // Extract direction and yaw from state.transformation. + // The transformation matrix encodes: T * R_Y(yaw) * S(-1,-1,1) + // where T is the direction-based translation. + Matrix4fc M = state.transformation.getMatrix(); + float tx = M.m30(); + float ty = M.m31(); + + // Determine direction from translation: + // Ground skulls: ty == 0 -> direction = null + // Wall skulls: ty == 0.25 -> direction is non-null + Direction direction; + float yaw; + + if (Math.abs(ty - 0.25F) < 0.01F) { + // Wall skull — determine direction from translation offsets + float tz = M.m32(); + direction = imaginefunutils$directionFromTranslation(tx, tz); + // Extract yaw: undo S(-1,-1,1) from the 3x3 to get pure rotation R_Y + yaw = imaginefunutils$extractYaw(M); + } else { + // Ground skull — direction is null + direction = null; + yaw = imaginefunutils$extractYaw(M); } - Identifier texture = getTextureLocation(renderSetup, "Sampler0"); + boolean success = PlayerHeadRenderer.render( + texture, + poseStack, + direction, + state.lightCoords, + yaw + ); + + if (success) { + ci.cancel(); + } + } + + /** + * Callers 2/3/4: CustomHeadLayer, PlayerHeadSpecialRenderer, SkullSpecialRenderer. + * + * In 1.21.11, these all passed direction=null and yaw=180.0F to submitSkull. + * In 26.1, submitSkull lost those parameters but the callers' intent is unchanged. + * + * This injection only fires for callers 2/3/4 because when caller 1's submit mixin + * succeeds, it cancels submit entirely (submitSkull is never called). + */ + @Inject( + method = "submitSkull(FLcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;ILnet/minecraft/client/model/object/skull/SkullModelBase;Lnet/minecraft/client/renderer/rendertype/RenderType;ILnet/minecraft/client/renderer/feature/ModelFeatureRenderer$CrumblingOverlay;)V", + at = @At("HEAD"), + cancellable = true + ) + private static void onStaticRender( + float animationValue, + PoseStack poseStack, + SubmitNodeCollector submitNodeCollector, + int lightCoords, + SkullModelBase model, + RenderType renderType, + int outlineColor, + ModelFeatureRenderer.CrumblingOverlay breakProgress, + CallbackInfo ci + ) { + Identifier texture = getTextureFromRenderType(renderType); if (texture == null) { return; } - poseStack.pushPose(); - poseStack.mulPose(state.transformation); - + // In 1.21.11, callers 2/3/4 always passed direction=null, yaw=180.0F boolean success = PlayerHeadRenderer.render( texture, poseStack, - state.lightCoords + null, + lightCoords, + 180.0F ); - poseStack.popPose(); - if (success) { ci.cancel(); } } + + /** + * Extract the Y-axis rotation angle (yaw) from the transformation matrix. + * The 3x3 part is R_Y(yaw) * S(-1,-1,1). We undo S(-1,-1,1) to get + * pure R_Y, then extract the angle. + */ + @Unique + private static float imaginefunutils$extractYaw(Matrix4fc M) { + // Get the 3x3 rotation+scale submatrix + Matrix3f rot = new Matrix3f(M); + // Undo S(-1,-1,1) by right-multiplying with S(-1,-1,1) (its own inverse) + rot.scale(-1, -1, 1); + // Now rot is pure R_Y(yaw). Extract yaw from atan2. + // R_Y = [[cos, 0, sin], [0, 1, 0], [-sin, 0, cos]] + // In JOML column-major: m00=cos, m20=sin, m02=-sin, m22=cos + float sin = rot.m20; + float cos = rot.m00; + return (float) Math.toDegrees(Math.atan2(sin, cos)); + } + + /** + * Determine the wall skull direction from the translation offsets. + * Wall translations: 0.5 - direction.getStepX() * 0.25, 0.25, 0.5 - direction.getStepZ() * 0.25 + */ + @Unique + private static Direction imaginefunutils$directionFromTranslation(float tx, float tz) { + // NORTH: stepX=0, stepZ=-1 -> tx=0.5, tz=0.75 + // SOUTH: stepX=0, stepZ=1 -> tx=0.5, tz=0.25 + // WEST: stepX=-1, stepZ=0 -> tx=0.75, tz=0.5 + // EAST: stepX=1, stepZ=0 -> tx=0.25, tz=0.5 + if (Math.abs(tz - 0.75F) < 0.01F) return Direction.NORTH; + if (Math.abs(tz - 0.25F) < 0.01F) return Direction.SOUTH; + if (Math.abs(tx - 0.75F) < 0.01F) return Direction.WEST; + if (Math.abs(tx - 0.25F) < 0.01F) return Direction.EAST; + return Direction.NORTH; // fallback + } } diff --git a/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java b/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java index db227ec..bdfda71 100644 --- a/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java +++ b/src/client/java/net/imaginefun/playerheads/PlayerHeadRenderer.java @@ -10,12 +10,15 @@ import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.PoseStack.Pose; import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.math.Axis; + import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.rendertype.RenderTypes; import net.minecraft.client.renderer.texture.AbstractTexture; import net.minecraft.client.renderer.texture.DynamicTexture; import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.Direction; import net.minecraft.resources.Identifier; public class PlayerHeadRenderer { @@ -30,11 +33,13 @@ public class PlayerHeadRenderer { private static final String NEW_NAMESPACE = "processed_images"; private static final Map processedTextures = new HashMap<>(); - + public static boolean render( Identifier skinTexture, PoseStack matrixStack, - int light + Direction direction, + int light, + float yaw ) { try { if (net.irisshaders.iris.api.v0.IrisApi.getInstance().isRenderingShadowPass()) { @@ -52,7 +57,7 @@ public static boolean render( } else { image = getTextureImageViaReflection(texture); } - + if(image == null) return false; int pixel = image.getPixel(MARKER_X, MARKER_Y); @@ -70,7 +75,7 @@ public static boolean render( int controlB = (controlPixel >> 0) & 0xFF; int controlA = (controlPixel >> 24) & 0xFF; - renderCustomSkull(light, matrixStack, skinTexture, + renderCustomSkull(direction, light, yaw, matrixStack, skinTexture, image, controlA, controlR, @@ -121,16 +126,10 @@ public static Identifier getNewImage(Identifier original, NativeImage input) { return newId; } - /** - * Renders a custom skull with the given texture and scale. - * - * @param skullBlockEntityRenderState The render state for the skull block entity - * @param matrixStack The matrix stack for transformations - * @param skinTexture The texture identifier for the skin - * @param scale The scale factor for the skull (based on alpha channel of marker pixel) - */ public static void renderCustomSkull( + Direction direction, int light, + float yaw, PoseStack matrixStack, Identifier skinTexture, NativeImage image, @@ -141,13 +140,17 @@ public static void renderCustomSkull( ) { matrixStack.pushPose(); - // Position/rotation already applied via state.transformation - // Apply additional scale (from -1,-1,1 to -1.1875,-1.1875,1.1875) and offset - matrixStack.scale(1.1875F, 1.1875F, 1.1875F); + if (direction == null) { + matrixStack.translate(0.5F, 0.0F, 0.5F); + } else { + matrixStack.translate(0.5F - direction.getStepX() * 0.25F, 0.25F, 0.5F - direction.getStepZ() * 0.25F); + } + matrixStack.scale(-1.1875F, -1.1875F, 1.1875F); + matrixStack.mulPose(Axis.YP.rotationDegrees(yaw)); matrixStack.translate(0.0F, -0.211F, -0.211F); int overlay = OverlayTexture.NO_OVERLAY; - + float scaleX = 0; float scaleY = 0; @@ -184,21 +187,21 @@ public static void renderCustomSkull( .setOverlay(overlay) .setLight(light) .setNormal(pose, 0, 0, -1); - + vertexConsumer.addVertex(matrix4f, scaleX, -scaleY, z) .setColor(255, 255, 255, 255) .setUv(maxU, 0) .setOverlay(overlay) .setLight(light) .setNormal(pose, 0, 0, -1); - + vertexConsumer.addVertex(matrix4f, scaleX, scaleY, z) .setColor(255, 255, 255, 255) .setUv(maxU, 1) .setOverlay(overlay) .setLight(light) .setNormal(pose, 0, 0, -1); - + vertexConsumer.addVertex(matrix4f, -scaleX, scaleY, z) .setColor(255, 255, 255, 255) .setUv(minU, 1) From 5f2b439b750f28735f3b6b5a780e65941870008c Mon Sep 17 00:00:00 2001 From: weikengchen Date: Sun, 29 Mar 2026 14:10:16 +0800 Subject: [PATCH 04/12] update CI to Java 25 for Minecraft 26.1 Minecraft 26.1 requires Java 25. Also update game-versions in publish workflow and switch to temurin distribution (microsoft doesn't ship JDK 25). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 4 ++-- .github/workflows/publish.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8fc3827..d4511a1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,8 +17,8 @@ jobs: - name: setup jdk uses: actions/setup-java@v4 with: - java-version: '21' - distribution: 'microsoft' + java-version: '25' + distribution: 'temurin' - name: make gradle wrapper executable run: chmod +x ./gradlew - name: build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 913970d..92a75d2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,8 +16,8 @@ jobs: - name: setup jdk uses: actions/setup-java@v4 with: - java-version: '21' - distribution: 'microsoft' + java-version: '25' + distribution: 'temurin' - name: make gradle wrapper executable run: chmod +x ./gradlew - name: build @@ -34,4 +34,4 @@ jobs: version: ${{ github.event.release.tag_name }} version-type: release loaders: fabric - game-versions: 1.21.11 + game-versions: 26.1 From 67e33b4b1e2e39a79eab7c3d8b8236704815f79f Mon Sep 17 00:00:00 2001 From: weikengchen Date: Sun, 29 Mar 2026 14:29:50 +0800 Subject: [PATCH 05/12] add permanent local texture cache for player head skins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses rate limiting when "video clip" player heads cycle through many unique textures rapidly. Caches all downloaded skin textures in a single binary file (imaginefunutils/head_textures.dat) that persists across game sessions. How it works: - Mixin into SkinTextureDownloader.downloadSkin() at HEAD and RETURN - HEAD: before Minecraft checks its file cache, pre-populate the file from our cache if the hash is found (avoids HTTP download entirely) - RETURN: after a successful load, persist the file bytes to our cache for future sessions No external dependencies — uses a simple append-friendly binary format with an in-memory ConcurrentHashMap for fast lookups. Writes are async via virtual threads to avoid blocking the render/IO thread. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../net/imaginefun/ImagineFunUtilsClient.java | 2 + .../net/imaginefun/cache/TextureCache.java | 123 ++++++++++++++++++ .../client/SkinTextureDownloaderMixin.java | 62 +++++++++ .../imaginefunutils.client.mixins.json | 1 + .../resources/imaginefunutils.accesswidener | 1 + 5 files changed, 189 insertions(+) create mode 100644 src/client/java/net/imaginefun/cache/TextureCache.java create mode 100644 src/client/java/net/imaginefun/mixins/client/SkinTextureDownloaderMixin.java diff --git a/src/client/java/net/imaginefun/ImagineFunUtilsClient.java b/src/client/java/net/imaginefun/ImagineFunUtilsClient.java index 0847851..3a645b2 100644 --- a/src/client/java/net/imaginefun/ImagineFunUtilsClient.java +++ b/src/client/java/net/imaginefun/ImagineFunUtilsClient.java @@ -4,6 +4,7 @@ import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.loader.api.FabricLoader; +import net.imaginefun.cache.TextureCache; import net.imaginefun.networking.ClientCustomPacketListener; import net.imaginefun.networking.ClientCustomPacketListenerImpl; import net.imaginefun.networking.HandshakePayload; @@ -16,6 +17,7 @@ public class ImagineFunUtilsClient implements ClientModInitializer { @Override public void onInitializeClient() { clientCustomPacketListener = new ClientCustomPacketListenerImpl(); + TextureCache.init(FabricLoader.getInstance().getGameDir()); ServerListPopulator.populate(); ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { diff --git a/src/client/java/net/imaginefun/cache/TextureCache.java b/src/client/java/net/imaginefun/cache/TextureCache.java new file mode 100644 index 0000000..6b06199 --- /dev/null +++ b/src/client/java/net/imaginefun/cache/TextureCache.java @@ -0,0 +1,123 @@ +package net.imaginefun.cache; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.concurrent.ConcurrentHashMap; + +import com.mojang.logging.LogUtils; +import org.slf4j.Logger; + +/** + * Permanent local cache for skin textures, stored as a single binary file. + * + * File format (append-friendly, no header): + * Sequence of entries, each: + * - hash length (2 bytes, unsigned short) + * - hash (UTF-8 string) + * - data length (4 bytes, int) + * - PNG data (raw bytes) + * + * On read, all entries are loaded into memory. On write, new entries are appended. + */ +public class TextureCache { + private static final Logger LOGGER = LogUtils.getLogger(); + + private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private static Path cacheFile; + private static boolean initialized = false; + private static final Object writeLock = new Object(); + + public static void init(Path gameDir) { + Path cacheDir = gameDir.resolve("imaginefunutils"); + cacheFile = cacheDir.resolve("head_textures.dat"); + + try { + Files.createDirectories(cacheDir); + } catch (IOException e) { + LOGGER.warn("Failed to create cache directory", e); + return; + } + + if (Files.isRegularFile(cacheFile)) { + try { + loadFromFile(); + } catch (IOException e) { + LOGGER.warn("Failed to load texture cache, starting fresh", e); + cache.clear(); + } + } + + initialized = true; + LOGGER.info("Texture cache initialized with {} entries", cache.size()); + } + + public static boolean has(String hash) { + return cache.containsKey(hash); + } + + public static byte[] get(String hash) { + return cache.get(hash); + } + + public static void putAsync(String hash, byte[] data) { + if (!initialized || cache.containsKey(hash)) { + return; + } + cache.put(hash, data); + + // Append to file on a background thread to avoid blocking the caller + Thread.ofVirtual().name("texture-cache-write").start(() -> { + synchronized (writeLock) { + try { + appendToFile(hash, data); + } catch (IOException e) { + LOGGER.warn("Failed to persist texture {} to cache", hash, e); + } + } + }); + } + + public static int size() { + return cache.size(); + } + + private static void loadFromFile() throws IOException { + try (DataInputStream in = new DataInputStream(new BufferedInputStream(Files.newInputStream(cacheFile)))) { + while (true) { + try { + int hashLen = in.readUnsignedShort(); + byte[] hashBytes = new byte[hashLen]; + in.readFully(hashBytes); + String hash = new String(hashBytes, java.nio.charset.StandardCharsets.UTF_8); + + int dataLen = in.readInt(); + byte[] data = new byte[dataLen]; + in.readFully(data); + + cache.put(hash, data); + } catch (EOFException e) { + // Reached end of file (or partial entry from a crash) — stop reading + break; + } + } + } + } + + private static void appendToFile(String hash, byte[] data) throws IOException { + byte[] hashBytes = hash.getBytes(java.nio.charset.StandardCharsets.UTF_8); + try (OutputStream raw = Files.newOutputStream(cacheFile, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + DataOutputStream out = new DataOutputStream(raw)) { + out.writeShort(hashBytes.length); + out.write(hashBytes); + out.writeInt(data.length); + out.write(data); + } + } +} diff --git a/src/client/java/net/imaginefun/mixins/client/SkinTextureDownloaderMixin.java b/src/client/java/net/imaginefun/mixins/client/SkinTextureDownloaderMixin.java new file mode 100644 index 0000000..6ea5f8b --- /dev/null +++ b/src/client/java/net/imaginefun/mixins/client/SkinTextureDownloaderMixin.java @@ -0,0 +1,62 @@ +package net.imaginefun.mixins.client; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.mojang.blaze3d.platform.NativeImage; + +import net.imaginefun.cache.TextureCache; +import net.minecraft.client.renderer.texture.SkinTextureDownloader; +import net.minecraft.util.FileUtil; + +/** + * Intercepts skin texture downloads to serve from and populate our permanent cache. + * + * HEAD: Before Minecraft checks its own file cache, pre-populate the file from our DB if available. + * RETURN: After a successful load (from file or download), persist the file bytes to our DB. + */ +@Mixin(SkinTextureDownloader.class) +public class SkinTextureDownloaderMixin { + + @Inject(method = "downloadSkin", at = @At("HEAD")) + private void beforeDownloadSkin(Path localCopy, String url, CallbackInfoReturnable cir) { + if (Files.isRegularFile(localCopy)) { + // File already exists — Minecraft will load from it, nothing to do + return; + } + + String hash = localCopy.getFileName().toString(); + byte[] cached = TextureCache.get(hash); + if (cached != null) { + try { + FileUtil.createDirectoriesSafe(localCopy.getParent()); + Files.write(localCopy, cached); + } catch (IOException e) { + // Failed to pre-populate — let normal download proceed + } + } + } + + @Inject(method = "downloadSkin", at = @At("RETURN")) + private void afterDownloadSkin(Path localCopy, String url, CallbackInfoReturnable cir) { + String hash = localCopy.getFileName().toString(); + if (TextureCache.has(hash)) { + return; + } + + if (Files.isRegularFile(localCopy)) { + try { + byte[] data = Files.readAllBytes(localCopy); + TextureCache.putAsync(hash, data); + } catch (IOException e) { + // Failed to read — skip caching + } + } + } +} diff --git a/src/client/resources/imaginefunutils.client.mixins.json b/src/client/resources/imaginefunutils.client.mixins.json index 6e66e5e..e1adf8f 100644 --- a/src/client/resources/imaginefunutils.client.mixins.json +++ b/src/client/resources/imaginefunutils.client.mixins.json @@ -8,6 +8,7 @@ "RenderTypeAccessor", "ScreenAccessor", "SkullBlockRendererMixin", + "SkinTextureDownloaderMixin", "TextureBindingAccessor", "TitleScreenMixin" ], diff --git a/src/main/resources/imaginefunutils.accesswidener b/src/main/resources/imaginefunutils.accesswidener index 0b481ad..ac80e8a 100644 --- a/src/main/resources/imaginefunutils.accesswidener +++ b/src/main/resources/imaginefunutils.accesswidener @@ -2,3 +2,4 @@ accessWidener v2 official accessible class net/minecraft/client/renderer/debug/GameTestBlockHighlightRenderer$Marker accessible class net/minecraft/client/renderer/rendertype/RenderSetup$TextureBinding +accessible method net/minecraft/client/renderer/texture/SkinTextureDownloader downloadSkin (Ljava/nio/file/Path;Ljava/lang/String;)Lcom/mojang/blaze3d/platform/NativeImage; From 1e9a6fb3512ee132d3f44b91c1f77a1562ecd5ad Mon Sep 17 00:00:00 2001 From: weikengchen Date: Sun, 29 Mar 2026 14:42:46 +0800 Subject: [PATCH 06/12] replace in-memory texture cache with H2 database + location preloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous in-memory HashMap cache with an H2 embedded database (pure Java, no native dependencies). Textures are stored on disk and loaded on-demand — only queried rows enter memory. New features: - Location tracking: when a custom head renders at a block position, the texture hash is associated with that chunk coordinate in the DB - Preloading: on each client tick, if the player moved to a new chunk, query the DB for all textures in nearby chunks (radius 4) and pre-populate Minecraft's skin file cache before they're requested - Shutdown hook closes the DB connection cleanly Schema: textures(hash PK, png_data BLOB) texture_locations(hash, world, chunk_x, chunk_z) with spatial index Co-Authored-By: Claude Opus 4.6 (1M context) --- build.gradle | 3 + .../net/imaginefun/ImagineFunUtilsClient.java | 6 + .../net/imaginefun/cache/TextureCache.java | 211 ++++++++++++------ .../imaginefun/cache/TexturePreloader.java | 86 +++++++ .../client/SkullBlockRendererMixin.java | 20 ++ 5 files changed, 253 insertions(+), 73 deletions(-) create mode 100644 src/client/java/net/imaginefun/cache/TexturePreloader.java diff --git a/build.gradle b/build.gradle index 7def1d6..79759c0 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,9 @@ dependencies { implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" compileOnly "maven.modrinth:iris:${project.iris_version}" + + implementation "com.h2database:h2:2.3.232" + include "com.h2database:h2:2.3.232" } processResources { diff --git a/src/client/java/net/imaginefun/ImagineFunUtilsClient.java b/src/client/java/net/imaginefun/ImagineFunUtilsClient.java index 3a645b2..ae4e6b9 100644 --- a/src/client/java/net/imaginefun/ImagineFunUtilsClient.java +++ b/src/client/java/net/imaginefun/ImagineFunUtilsClient.java @@ -1,10 +1,12 @@ package net.imaginefun; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.loader.api.FabricLoader; import net.imaginefun.cache.TextureCache; +import net.imaginefun.cache.TexturePreloader; import net.imaginefun.networking.ClientCustomPacketListener; import net.imaginefun.networking.ClientCustomPacketListenerImpl; import net.imaginefun.networking.HandshakePayload; @@ -28,6 +30,10 @@ public void onInitializeClient() { ClientPlayNetworking.send(new HandshakePayload(version)); }); + ClientTickEvents.END_CLIENT_TICK.register(TexturePreloader::tick); + + Runtime.getRuntime().addShutdownHook(new Thread(TextureCache::close, "texture-cache-shutdown")); + } public ClientCustomPacketListener getClientCustomPacketListener() { diff --git a/src/client/java/net/imaginefun/cache/TextureCache.java b/src/client/java/net/imaginefun/cache/TextureCache.java index 6b06199..ade344b 100644 --- a/src/client/java/net/imaginefun/cache/TextureCache.java +++ b/src/client/java/net/imaginefun/cache/TextureCache.java @@ -1,43 +1,37 @@ package net.imaginefun.cache; -import java.io.BufferedInputStream; -import java.io.DataInputStream; -import java.io.DataOutputStream; -import java.io.EOFException; import java.io.IOException; -import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.concurrent.ConcurrentHashMap; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; import com.mojang.logging.LogUtils; +import net.minecraft.util.FileUtil; import org.slf4j.Logger; /** - * Permanent local cache for skin textures, stored as a single binary file. + * Permanent local cache for skin textures using H2 embedded database. + * Data lives on disk; only queried rows are loaded into memory. * - * File format (append-friendly, no header): - * Sequence of entries, each: - * - hash length (2 bytes, unsigned short) - * - hash (UTF-8 string) - * - data length (4 bytes, int) - * - PNG data (raw bytes) - * - * On read, all entries are loaded into memory. On write, new entries are appended. + * Supports location-based preloading: tracks which texture hashes appear + * near which chunk coordinates so nearby textures can be pre-populated + * into Minecraft's file cache before the render pipeline requests them. */ public class TextureCache { private static final Logger LOGGER = LogUtils.getLogger(); - private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); - private static Path cacheFile; + private static Connection connection; private static boolean initialized = false; - private static final Object writeLock = new Object(); public static void init(Path gameDir) { Path cacheDir = gameDir.resolve("imaginefunutils"); - cacheFile = cacheDir.resolve("head_textures.dat"); - try { Files.createDirectories(cacheDir); } catch (IOException e) { @@ -45,79 +39,150 @@ public static void init(Path gameDir) { return; } - if (Files.isRegularFile(cacheFile)) { + String dbPath = cacheDir.resolve("head_textures").toAbsolutePath().toString(); + try { + Class.forName("org.h2.Driver"); + connection = DriverManager.getConnection("jdbc:h2:" + dbPath + ";MODE=MySQL", "sa", ""); + createTables(); + initialized = true; + + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM textures")) { + rs.next(); + LOGGER.info("Texture cache initialized with {} entries", rs.getInt(1)); + } + } catch (Exception e) { + LOGGER.warn("Failed to initialize texture cache database", e); + } + } + + public static void close() { + if (connection != null) { try { - loadFromFile(); - } catch (IOException e) { - LOGGER.warn("Failed to load texture cache, starting fresh", e); - cache.clear(); + connection.close(); + } catch (SQLException e) { + LOGGER.warn("Failed to close texture cache database", e); } } + } - initialized = true; - LOGGER.info("Texture cache initialized with {} entries", cache.size()); + private static void createTables() throws SQLException { + try (Statement stmt = connection.createStatement()) { + stmt.execute(""" + CREATE TABLE IF NOT EXISTS textures ( + hash VARCHAR(128) PRIMARY KEY, + png_data BLOB NOT NULL + ) + """); + stmt.execute(""" + CREATE TABLE IF NOT EXISTS texture_locations ( + hash VARCHAR(128) NOT NULL, + world VARCHAR(256) NOT NULL, + chunk_x INTEGER NOT NULL, + chunk_z INTEGER NOT NULL, + PRIMARY KEY (hash, world, chunk_x, chunk_z) + ) + """); + stmt.execute(""" + CREATE INDEX IF NOT EXISTS idx_location + ON texture_locations (world, chunk_x, chunk_z) + """); + } } + // --- Texture data --- + public static boolean has(String hash) { - return cache.containsKey(hash); + if (!initialized) return false; + try (PreparedStatement ps = connection.prepareStatement("SELECT 1 FROM textures WHERE hash = ?")) { + ps.setString(1, hash); + return ps.executeQuery().next(); + } catch (SQLException e) { + LOGGER.warn("Failed to check texture cache for {}", hash, e); + return false; + } } public static byte[] get(String hash) { - return cache.get(hash); + if (!initialized) return null; + try (PreparedStatement ps = connection.prepareStatement("SELECT png_data FROM textures WHERE hash = ?")) { + ps.setString(1, hash); + ResultSet rs = ps.executeQuery(); + if (rs.next()) { + return rs.getBytes("png_data"); + } + return null; + } catch (SQLException e) { + LOGGER.warn("Failed to read texture {} from cache", hash, e); + return null; + } } public static void putAsync(String hash, byte[] data) { - if (!initialized || cache.containsKey(hash)) { - return; + if (!initialized) return; + Thread.ofVirtual().name("texture-cache-write").start(() -> put(hash, data)); + } + + private static synchronized void put(String hash, byte[] data) { + try (PreparedStatement ps = connection.prepareStatement( + "MERGE INTO textures (hash, png_data) KEY (hash) VALUES (?, ?)")) { + ps.setString(1, hash); + ps.setBytes(2, data); + ps.executeUpdate(); + } catch (SQLException e) { + LOGGER.warn("Failed to write texture {} to cache", hash, e); } - cache.put(hash, data); - - // Append to file on a background thread to avoid blocking the caller - Thread.ofVirtual().name("texture-cache-write").start(() -> { - synchronized (writeLock) { - try { - appendToFile(hash, data); - } catch (IOException e) { - LOGGER.warn("Failed to persist texture {} to cache", hash, e); - } - } - }); } - public static int size() { - return cache.size(); + // --- Location tracking --- + + public static void recordLocationAsync(String hash, String world, int chunkX, int chunkZ) { + if (!initialized) return; + Thread.ofVirtual().name("texture-cache-location").start(() -> recordLocation(hash, world, chunkX, chunkZ)); } - private static void loadFromFile() throws IOException { - try (DataInputStream in = new DataInputStream(new BufferedInputStream(Files.newInputStream(cacheFile)))) { - while (true) { - try { - int hashLen = in.readUnsignedShort(); - byte[] hashBytes = new byte[hashLen]; - in.readFully(hashBytes); - String hash = new String(hashBytes, java.nio.charset.StandardCharsets.UTF_8); - - int dataLen = in.readInt(); - byte[] data = new byte[dataLen]; - in.readFully(data); - - cache.put(hash, data); - } catch (EOFException e) { - // Reached end of file (or partial entry from a crash) — stop reading - break; - } - } + private static synchronized void recordLocation(String hash, String world, int chunkX, int chunkZ) { + try (PreparedStatement ps = connection.prepareStatement( + "MERGE INTO texture_locations (hash, world, chunk_x, chunk_z) KEY (hash, world, chunk_x, chunk_z) VALUES (?, ?, ?, ?)")) { + ps.setString(1, hash); + ps.setString(2, world); + ps.setInt(3, chunkX); + ps.setInt(4, chunkZ); + ps.executeUpdate(); + } catch (SQLException e) { + LOGGER.warn("Failed to record texture location", e); } } - private static void appendToFile(String hash, byte[] data) throws IOException { - byte[] hashBytes = hash.getBytes(java.nio.charset.StandardCharsets.UTF_8); - try (OutputStream raw = Files.newOutputStream(cacheFile, StandardOpenOption.CREATE, StandardOpenOption.APPEND); - DataOutputStream out = new DataOutputStream(raw)) { - out.writeShort(hashBytes.length); - out.write(hashBytes); - out.writeInt(data.length); - out.write(data); + /** + * Returns all texture hashes associated with chunks in the given region. + * Used for preloading textures when the player approaches an area. + */ + public static List getTexturesNear(String world, int chunkX, int chunkZ, int radius) { + if (!initialized) return List.of(); + List results = new ArrayList<>(); + try (PreparedStatement ps = connection.prepareStatement(""" + SELECT DISTINCT t.hash, t.png_data + FROM textures t + JOIN texture_locations l ON t.hash = l.hash + WHERE l.world = ? + AND l.chunk_x BETWEEN ? AND ? + AND l.chunk_z BETWEEN ? AND ? + """)) { + ps.setString(1, world); + ps.setInt(2, chunkX - radius); + ps.setInt(3, chunkX + radius); + ps.setInt(4, chunkZ - radius); + ps.setInt(5, chunkZ + radius); + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + results.add(new CachedTexture(rs.getString("hash"), rs.getBytes("png_data"))); + } + } catch (SQLException e) { + LOGGER.warn("Failed to query textures near ({}, {})", chunkX, chunkZ, e); } + return results; } + + public record CachedTexture(String hash, byte[] data) {} } diff --git a/src/client/java/net/imaginefun/cache/TexturePreloader.java b/src/client/java/net/imaginefun/cache/TexturePreloader.java new file mode 100644 index 0000000..5410240 --- /dev/null +++ b/src/client/java/net/imaginefun/cache/TexturePreloader.java @@ -0,0 +1,86 @@ +package net.imaginefun.cache; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.util.FileUtil; +import org.slf4j.Logger; + +/** + * Pre-populates Minecraft's skin file cache with textures from our DB + * for chunks near the player's current position. + */ +public class TexturePreloader { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final int PRELOAD_RADIUS = 4; // chunks + + private static int lastChunkX = Integer.MIN_VALUE; + private static int lastChunkZ = Integer.MIN_VALUE; + private static String lastWorld = ""; + + /** + * Called each client tick. Checks if the player has moved to a new chunk + * and triggers preloading if so. + */ + public static void tick(Minecraft client) { + if (client.player == null || client.level == null) { + return; + } + + int chunkX = client.player.getBlockX() >> 4; + int chunkZ = client.player.getBlockZ() >> 4; + String world = client.level.dimension().identifier().toString(); + + if (chunkX == lastChunkX && chunkZ == lastChunkZ && world.equals(lastWorld)) { + return; + } + + lastChunkX = chunkX; + lastChunkZ = chunkZ; + lastWorld = world; + + Thread.ofVirtual().name("texture-preloader").start(() -> preload(world, chunkX, chunkZ)); + } + + private static void preload(String world, int chunkX, int chunkZ) { + List textures = TextureCache.getTexturesNear(world, chunkX, chunkZ, PRELOAD_RADIUS); + if (textures.isEmpty()) { + return; + } + + Path skinsDir = Minecraft.getInstance().gameDirectory.toPath().resolve("assets").resolve("skins"); + int preloaded = 0; + + for (TextureCache.CachedTexture texture : textures) { + String hash = texture.hash(); + Path dir = skinsDir.resolve(hash.length() > 2 ? hash.substring(0, 2) : "xx"); + Path file = dir.resolve(hash); + + if (Files.isRegularFile(file)) { + continue; // already in Minecraft's cache + } + + try { + FileUtil.createDirectoriesSafe(dir); + Files.write(file, texture.data()); + preloaded++; + } catch (IOException e) { + // Skip this texture + } + } + + if (preloaded > 0) { + LOGGER.debug("Preloaded {} textures for chunks near ({}, {})", preloaded, chunkX, chunkZ); + } + } + + public static void reset() { + lastChunkX = Integer.MIN_VALUE; + lastChunkZ = Integer.MIN_VALUE; + lastWorld = ""; + } +} diff --git a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java index ed0e83e..0482c71 100644 --- a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java +++ b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java @@ -12,14 +12,18 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import net.imaginefun.cache.TextureCache; import net.imaginefun.playerheads.PlayerHeadRenderer; import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.Minecraft; + import net.minecraft.client.model.object.skull.SkullModelBase; import net.minecraft.client.renderer.SubmitNodeCollector; import net.minecraft.client.renderer.blockentity.SkullBlockRenderer; import net.minecraft.client.renderer.blockentity.state.SkullBlockRenderState; +import net.minecraft.core.BlockPos; import net.minecraft.client.renderer.feature.ModelFeatureRenderer; import net.minecraft.client.renderer.rendertype.RenderSetup; import net.minecraft.client.renderer.rendertype.RenderType; @@ -116,10 +120,26 @@ private void onSubmit( ); if (success) { + // Record which texture hash appears at this block position for preloading + imaginefunutils$recordTextureLocation(texture, state.blockPos); ci.cancel(); } } + @Unique + private static void imaginefunutils$recordTextureLocation(Identifier texture, BlockPos pos) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null) return; + + String path = texture.getPath(); + int lastSlash = path.lastIndexOf('/'); + if (lastSlash < 0) return; + String hash = path.substring(lastSlash + 1); + + String world = mc.level.dimension().identifier().toString(); + TextureCache.recordLocationAsync(hash, world, pos.getX() >> 4, pos.getZ() >> 4); + } + /** * Callers 2/3/4: CustomHeadLayer, PlayerHeadSpecialRenderer, SkullSpecialRenderer. * From 800189750ab69e78256f99586b4607a6bfd42f48 Mon Sep 17 00:00:00 2001 From: weikengchen Date: Mon, 30 Mar 2026 02:40:53 +0800 Subject: [PATCH 07/12] port mod from 1.21.11 to 26.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update build to Java 25 (Minecraft 26.1 requirement) - Remove texture cache feature (TextureCache, TexturePreloader, SkinTextureDownloaderMixin) — deferred to separate mod - Replace TitleScreenMixin with ScreenEvents.AFTER_INIT for ModMenu compatibility - Rewrite SkullBlockRendererMixin for 26.1 API changes: - Route A: extract direction/yaw from state.transformation matrix (26.1 removed direction/yaw params from submitSkull), negate yaw to match 1.21.11 sign convention - Route B: detect CustomHeadLayer caller and apply scale(1,-1,-1) + translate(-0.5,0,-0.5) to restore 1.21.11 poseStack state (26.1 changed CustomHeadLayer's scale signs and removed translate) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 + build.gradle | 9 +- gradle.properties | 1 + .../net/imaginefun/ImagineFunUtilsClient.java | 62 +++++- .../net/imaginefun/cache/TextureCache.java | 188 ------------------ .../imaginefun/cache/TexturePreloader.java | 86 -------- .../mixins/client/ScreenAccessor.java | 15 -- .../client/SkinTextureDownloaderMixin.java | 62 ------ .../client/SkullBlockRendererMixin.java | 60 +++--- .../mixins/client/TitleScreenMixin.java | 60 ------ .../imaginefunutils.client.mixins.json | 5 +- src/main/resources/fabric.mod.json | 2 +- .../resources/imaginefunutils.accesswidener | 1 - 13 files changed, 101 insertions(+), 454 deletions(-) delete mode 100644 src/client/java/net/imaginefun/cache/TextureCache.java delete mode 100644 src/client/java/net/imaginefun/cache/TexturePreloader.java delete mode 100644 src/client/java/net/imaginefun/mixins/client/ScreenAccessor.java delete mode 100644 src/client/java/net/imaginefun/mixins/client/SkinTextureDownloaderMixin.java delete mode 100644 src/client/java/net/imaginefun/mixins/client/TitleScreenMixin.java diff --git a/.gitignore b/.gitignore index c476faf..7e48d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ hs_err_*.log replay_*.log *.hprof *.jfr + +# local scripts + +build-and-deploy.sh diff --git a/build.gradle b/build.gradle index 79759c0..d642e60 100644 --- a/build.gradle +++ b/build.gradle @@ -44,9 +44,6 @@ dependencies { implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" compileOnly "maven.modrinth:iris:${project.iris_version}" - - implementation "com.h2database:h2:2.3.232" - include "com.h2database:h2:2.3.232" } processResources { @@ -58,7 +55,7 @@ processResources { } tasks.withType(JavaCompile).configureEach { - it.options.release = 21 + it.options.release = 25 } java { @@ -67,8 +64,8 @@ java { // If you remove this line, sources will not be generated. withSourcesJar() - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 } jar { diff --git a/gradle.properties b/gradle.properties index 8bfd4d2..0a70fb3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,6 @@ # Done to increase the memory available to gradle. org.gradle.jvmargs=-Xmx1G +org.gradle.java.home=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home org.gradle.parallel=true # IntelliJ IDEA is not yet fully compatible with configuration cache, see: https://github.com/FabricMC/fabric-loom/issues/1349 diff --git a/src/client/java/net/imaginefun/ImagineFunUtilsClient.java b/src/client/java/net/imaginefun/ImagineFunUtilsClient.java index ae4e6b9..977a081 100644 --- a/src/client/java/net/imaginefun/ImagineFunUtilsClient.java +++ b/src/client/java/net/imaginefun/ImagineFunUtilsClient.java @@ -1,16 +1,22 @@ package net.imaginefun; import net.fabricmc.api.ClientModInitializer; -import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; import net.fabricmc.loader.api.FabricLoader; -import net.imaginefun.cache.TextureCache; -import net.imaginefun.cache.TexturePreloader; +import net.imaginefun.gui.ImagineFunButton; import net.imaginefun.networking.ClientCustomPacketListener; import net.imaginefun.networking.ClientCustomPacketListenerImpl; import net.imaginefun.networking.HandshakePayload; import net.imaginefun.servers.ServerListPopulator; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.ConnectScreen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.client.multiplayer.resolver.ServerAddress; +import net.minecraft.network.chat.Component; public class ImagineFunUtilsClient implements ClientModInitializer { @@ -19,7 +25,6 @@ public class ImagineFunUtilsClient implements ClientModInitializer { @Override public void onInitializeClient() { clientCustomPacketListener = new ClientCustomPacketListenerImpl(); - TextureCache.init(FabricLoader.getInstance().getGameDir()); ServerListPopulator.populate(); ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { @@ -30,9 +35,54 @@ public void onInitializeClient() { ClientPlayNetworking.send(new HandshakePayload(version)); }); - ClientTickEvents.END_CLIENT_TICK.register(TexturePreloader::tick); + ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> { + if (!(screen instanceof TitleScreen)) return; - Runtime.getRuntime().addShutdownHook(new Thread(TextureCache::close, "texture-cache-shutdown")); + // Screens.getWidgets() is the Fabric API-provided modifiable widget list. + // Adding to it is the correct way to register widgets from outside a Screen + // subclass (addRenderableWidget is protected). This is the same approach + // used by ModMenu. + var widgets = Screens.getWidgets(screen); + + // Find the Singleplayer button dynamically so we anchor to its actual + // position after all other mods (e.g. ModMenu) have already shifted things + // in their own AFTER_INIT or @Inject TAIL handlers. + int singleplayerIndex = -1; + AbstractWidget singleplayerButton = null; + String singleplayerText = Component.translatable("menu.singleplayer").getString(); + for (int i = 0; i < widgets.size(); i++) { + AbstractWidget widget = widgets.get(i); + if (singleplayerText.equals(widget.getMessage().getString())) { + singleplayerIndex = i; + singleplayerButton = widget; + break; + } + } + if (singleplayerButton == null) return; + + int y = singleplayerButton.getY() + 24; + int x = screen.width / 2 - 100; + + for (AbstractWidget widget : widgets) { + if (widget != singleplayerButton && widget.getY() >= y) { + widget.setY(widget.getY() + 24); + } + } + + widgets.add(singleplayerIndex + 1, new ImagineFunButton( + x, y, 200, 20, + Component.literal("Join " + ImaginefunUtilsConstants.serverName), + button -> { + ServerData serverData = new ServerData( + ImaginefunUtilsConstants.serverName, + ImaginefunUtilsConstants.serverAddress, + ServerData.Type.OTHER + ); + serverData.setResourcePackStatus(ServerData.ServerPackStatus.ENABLED); + ConnectScreen.startConnecting(screen, client, ServerAddress.parseString(ImaginefunUtilsConstants.serverAddress), serverData, false, null); + } + )); + }); } diff --git a/src/client/java/net/imaginefun/cache/TextureCache.java b/src/client/java/net/imaginefun/cache/TextureCache.java deleted file mode 100644 index ade344b..0000000 --- a/src/client/java/net/imaginefun/cache/TextureCache.java +++ /dev/null @@ -1,188 +0,0 @@ -package net.imaginefun.cache; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.ArrayList; -import java.util.List; - -import com.mojang.logging.LogUtils; -import net.minecraft.util.FileUtil; -import org.slf4j.Logger; - -/** - * Permanent local cache for skin textures using H2 embedded database. - * Data lives on disk; only queried rows are loaded into memory. - * - * Supports location-based preloading: tracks which texture hashes appear - * near which chunk coordinates so nearby textures can be pre-populated - * into Minecraft's file cache before the render pipeline requests them. - */ -public class TextureCache { - private static final Logger LOGGER = LogUtils.getLogger(); - - private static Connection connection; - private static boolean initialized = false; - - public static void init(Path gameDir) { - Path cacheDir = gameDir.resolve("imaginefunutils"); - try { - Files.createDirectories(cacheDir); - } catch (IOException e) { - LOGGER.warn("Failed to create cache directory", e); - return; - } - - String dbPath = cacheDir.resolve("head_textures").toAbsolutePath().toString(); - try { - Class.forName("org.h2.Driver"); - connection = DriverManager.getConnection("jdbc:h2:" + dbPath + ";MODE=MySQL", "sa", ""); - createTables(); - initialized = true; - - try (Statement stmt = connection.createStatement(); - ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM textures")) { - rs.next(); - LOGGER.info("Texture cache initialized with {} entries", rs.getInt(1)); - } - } catch (Exception e) { - LOGGER.warn("Failed to initialize texture cache database", e); - } - } - - public static void close() { - if (connection != null) { - try { - connection.close(); - } catch (SQLException e) { - LOGGER.warn("Failed to close texture cache database", e); - } - } - } - - private static void createTables() throws SQLException { - try (Statement stmt = connection.createStatement()) { - stmt.execute(""" - CREATE TABLE IF NOT EXISTS textures ( - hash VARCHAR(128) PRIMARY KEY, - png_data BLOB NOT NULL - ) - """); - stmt.execute(""" - CREATE TABLE IF NOT EXISTS texture_locations ( - hash VARCHAR(128) NOT NULL, - world VARCHAR(256) NOT NULL, - chunk_x INTEGER NOT NULL, - chunk_z INTEGER NOT NULL, - PRIMARY KEY (hash, world, chunk_x, chunk_z) - ) - """); - stmt.execute(""" - CREATE INDEX IF NOT EXISTS idx_location - ON texture_locations (world, chunk_x, chunk_z) - """); - } - } - - // --- Texture data --- - - public static boolean has(String hash) { - if (!initialized) return false; - try (PreparedStatement ps = connection.prepareStatement("SELECT 1 FROM textures WHERE hash = ?")) { - ps.setString(1, hash); - return ps.executeQuery().next(); - } catch (SQLException e) { - LOGGER.warn("Failed to check texture cache for {}", hash, e); - return false; - } - } - - public static byte[] get(String hash) { - if (!initialized) return null; - try (PreparedStatement ps = connection.prepareStatement("SELECT png_data FROM textures WHERE hash = ?")) { - ps.setString(1, hash); - ResultSet rs = ps.executeQuery(); - if (rs.next()) { - return rs.getBytes("png_data"); - } - return null; - } catch (SQLException e) { - LOGGER.warn("Failed to read texture {} from cache", hash, e); - return null; - } - } - - public static void putAsync(String hash, byte[] data) { - if (!initialized) return; - Thread.ofVirtual().name("texture-cache-write").start(() -> put(hash, data)); - } - - private static synchronized void put(String hash, byte[] data) { - try (PreparedStatement ps = connection.prepareStatement( - "MERGE INTO textures (hash, png_data) KEY (hash) VALUES (?, ?)")) { - ps.setString(1, hash); - ps.setBytes(2, data); - ps.executeUpdate(); - } catch (SQLException e) { - LOGGER.warn("Failed to write texture {} to cache", hash, e); - } - } - - // --- Location tracking --- - - public static void recordLocationAsync(String hash, String world, int chunkX, int chunkZ) { - if (!initialized) return; - Thread.ofVirtual().name("texture-cache-location").start(() -> recordLocation(hash, world, chunkX, chunkZ)); - } - - private static synchronized void recordLocation(String hash, String world, int chunkX, int chunkZ) { - try (PreparedStatement ps = connection.prepareStatement( - "MERGE INTO texture_locations (hash, world, chunk_x, chunk_z) KEY (hash, world, chunk_x, chunk_z) VALUES (?, ?, ?, ?)")) { - ps.setString(1, hash); - ps.setString(2, world); - ps.setInt(3, chunkX); - ps.setInt(4, chunkZ); - ps.executeUpdate(); - } catch (SQLException e) { - LOGGER.warn("Failed to record texture location", e); - } - } - - /** - * Returns all texture hashes associated with chunks in the given region. - * Used for preloading textures when the player approaches an area. - */ - public static List getTexturesNear(String world, int chunkX, int chunkZ, int radius) { - if (!initialized) return List.of(); - List results = new ArrayList<>(); - try (PreparedStatement ps = connection.prepareStatement(""" - SELECT DISTINCT t.hash, t.png_data - FROM textures t - JOIN texture_locations l ON t.hash = l.hash - WHERE l.world = ? - AND l.chunk_x BETWEEN ? AND ? - AND l.chunk_z BETWEEN ? AND ? - """)) { - ps.setString(1, world); - ps.setInt(2, chunkX - radius); - ps.setInt(3, chunkX + radius); - ps.setInt(4, chunkZ - radius); - ps.setInt(5, chunkZ + radius); - ResultSet rs = ps.executeQuery(); - while (rs.next()) { - results.add(new CachedTexture(rs.getString("hash"), rs.getBytes("png_data"))); - } - } catch (SQLException e) { - LOGGER.warn("Failed to query textures near ({}, {})", chunkX, chunkZ, e); - } - return results; - } - - public record CachedTexture(String hash, byte[] data) {} -} diff --git a/src/client/java/net/imaginefun/cache/TexturePreloader.java b/src/client/java/net/imaginefun/cache/TexturePreloader.java deleted file mode 100644 index 5410240..0000000 --- a/src/client/java/net/imaginefun/cache/TexturePreloader.java +++ /dev/null @@ -1,86 +0,0 @@ -package net.imaginefun.cache; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import com.mojang.logging.LogUtils; -import net.minecraft.client.Minecraft; -import net.minecraft.util.FileUtil; -import org.slf4j.Logger; - -/** - * Pre-populates Minecraft's skin file cache with textures from our DB - * for chunks near the player's current position. - */ -public class TexturePreloader { - private static final Logger LOGGER = LogUtils.getLogger(); - private static final int PRELOAD_RADIUS = 4; // chunks - - private static int lastChunkX = Integer.MIN_VALUE; - private static int lastChunkZ = Integer.MIN_VALUE; - private static String lastWorld = ""; - - /** - * Called each client tick. Checks if the player has moved to a new chunk - * and triggers preloading if so. - */ - public static void tick(Minecraft client) { - if (client.player == null || client.level == null) { - return; - } - - int chunkX = client.player.getBlockX() >> 4; - int chunkZ = client.player.getBlockZ() >> 4; - String world = client.level.dimension().identifier().toString(); - - if (chunkX == lastChunkX && chunkZ == lastChunkZ && world.equals(lastWorld)) { - return; - } - - lastChunkX = chunkX; - lastChunkZ = chunkZ; - lastWorld = world; - - Thread.ofVirtual().name("texture-preloader").start(() -> preload(world, chunkX, chunkZ)); - } - - private static void preload(String world, int chunkX, int chunkZ) { - List textures = TextureCache.getTexturesNear(world, chunkX, chunkZ, PRELOAD_RADIUS); - if (textures.isEmpty()) { - return; - } - - Path skinsDir = Minecraft.getInstance().gameDirectory.toPath().resolve("assets").resolve("skins"); - int preloaded = 0; - - for (TextureCache.CachedTexture texture : textures) { - String hash = texture.hash(); - Path dir = skinsDir.resolve(hash.length() > 2 ? hash.substring(0, 2) : "xx"); - Path file = dir.resolve(hash); - - if (Files.isRegularFile(file)) { - continue; // already in Minecraft's cache - } - - try { - FileUtil.createDirectoriesSafe(dir); - Files.write(file, texture.data()); - preloaded++; - } catch (IOException e) { - // Skip this texture - } - } - - if (preloaded > 0) { - LOGGER.debug("Preloaded {} textures for chunks near ({}, {})", preloaded, chunkX, chunkZ); - } - } - - public static void reset() { - lastChunkX = Integer.MIN_VALUE; - lastChunkZ = Integer.MIN_VALUE; - lastWorld = ""; - } -} diff --git a/src/client/java/net/imaginefun/mixins/client/ScreenAccessor.java b/src/client/java/net/imaginefun/mixins/client/ScreenAccessor.java deleted file mode 100644 index 4b18994..0000000 --- a/src/client/java/net/imaginefun/mixins/client/ScreenAccessor.java +++ /dev/null @@ -1,15 +0,0 @@ -package net.imaginefun.mixins.client; - -import java.util.List; - -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.gen.Accessor; - -import net.minecraft.client.gui.components.Renderable; -import net.minecraft.client.gui.screens.Screen; - -@Mixin(Screen.class) -public interface ScreenAccessor { - @Accessor("renderables") - List imaginefunutils$getRenderables(); -} diff --git a/src/client/java/net/imaginefun/mixins/client/SkinTextureDownloaderMixin.java b/src/client/java/net/imaginefun/mixins/client/SkinTextureDownloaderMixin.java deleted file mode 100644 index 6ea5f8b..0000000 --- a/src/client/java/net/imaginefun/mixins/client/SkinTextureDownloaderMixin.java +++ /dev/null @@ -1,62 +0,0 @@ -package net.imaginefun.mixins.client; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; - -import com.mojang.blaze3d.platform.NativeImage; - -import net.imaginefun.cache.TextureCache; -import net.minecraft.client.renderer.texture.SkinTextureDownloader; -import net.minecraft.util.FileUtil; - -/** - * Intercepts skin texture downloads to serve from and populate our permanent cache. - * - * HEAD: Before Minecraft checks its own file cache, pre-populate the file from our DB if available. - * RETURN: After a successful load (from file or download), persist the file bytes to our DB. - */ -@Mixin(SkinTextureDownloader.class) -public class SkinTextureDownloaderMixin { - - @Inject(method = "downloadSkin", at = @At("HEAD")) - private void beforeDownloadSkin(Path localCopy, String url, CallbackInfoReturnable cir) { - if (Files.isRegularFile(localCopy)) { - // File already exists — Minecraft will load from it, nothing to do - return; - } - - String hash = localCopy.getFileName().toString(); - byte[] cached = TextureCache.get(hash); - if (cached != null) { - try { - FileUtil.createDirectoriesSafe(localCopy.getParent()); - Files.write(localCopy, cached); - } catch (IOException e) { - // Failed to pre-populate — let normal download proceed - } - } - } - - @Inject(method = "downloadSkin", at = @At("RETURN")) - private void afterDownloadSkin(Path localCopy, String url, CallbackInfoReturnable cir) { - String hash = localCopy.getFileName().toString(); - if (TextureCache.has(hash)) { - return; - } - - if (Files.isRegularFile(localCopy)) { - try { - byte[] data = Files.readAllBytes(localCopy); - TextureCache.putAsync(hash, data); - } catch (IOException e) { - // Failed to read — skip caching - } - } - } -} diff --git a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java index 0482c71..12dde24 100644 --- a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java +++ b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java @@ -12,18 +12,14 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import net.imaginefun.cache.TextureCache; import net.imaginefun.playerheads.PlayerHeadRenderer; import com.mojang.blaze3d.vertex.PoseStack; -import net.minecraft.client.Minecraft; - import net.minecraft.client.model.object.skull.SkullModelBase; import net.minecraft.client.renderer.SubmitNodeCollector; import net.minecraft.client.renderer.blockentity.SkullBlockRenderer; import net.minecraft.client.renderer.blockentity.state.SkullBlockRenderState; -import net.minecraft.core.BlockPos; import net.minecraft.client.renderer.feature.ModelFeatureRenderer; import net.minecraft.client.renderer.rendertype.RenderSetup; import net.minecraft.client.renderer.rendertype.RenderType; @@ -87,8 +83,9 @@ private void onSubmit( } // Extract direction and yaw from state.transformation. - // The transformation matrix encodes: T * R_Y(yaw) * S(-1,-1,1) - // where T is the direction-based translation. + // The transformation matrix encodes: T * R_Y(-yaw) * S(-1,-1,1) + // where T is the direction-based translation and yaw is negated + // compared to 1.21.11's rotationDegrees. We negate extractYaw to match. Matrix4fc M = state.transformation.getMatrix(); float tx = M.m30(); float ty = M.m31(); @@ -103,12 +100,11 @@ private void onSubmit( // Wall skull — determine direction from translation offsets float tz = M.m32(); direction = imaginefunutils$directionFromTranslation(tx, tz); - // Extract yaw: undo S(-1,-1,1) from the 3x3 to get pure rotation R_Y - yaw = imaginefunutils$extractYaw(M); + yaw = -imaginefunutils$extractYaw(M); } else { // Ground skull — direction is null direction = null; - yaw = imaginefunutils$extractYaw(M); + yaw = -imaginefunutils$extractYaw(M); } boolean success = PlayerHeadRenderer.render( @@ -120,26 +116,10 @@ private void onSubmit( ); if (success) { - // Record which texture hash appears at this block position for preloading - imaginefunutils$recordTextureLocation(texture, state.blockPos); ci.cancel(); } } - @Unique - private static void imaginefunutils$recordTextureLocation(Identifier texture, BlockPos pos) { - Minecraft mc = Minecraft.getInstance(); - if (mc.level == null) return; - - String path = texture.getPath(); - int lastSlash = path.lastIndexOf('/'); - if (lastSlash < 0) return; - String hash = path.substring(lastSlash + 1); - - String world = mc.level.dimension().identifier().toString(); - TextureCache.recordLocationAsync(hash, world, pos.getX() >> 4, pos.getZ() >> 4); - } - /** * Callers 2/3/4: CustomHeadLayer, PlayerHeadSpecialRenderer, SkullSpecialRenderer. * @@ -170,6 +150,22 @@ private static void onStaticRender( return; } + // In 26.1, CustomHeadLayer changed two things vs 1.21.11: + // 1. scale(1.1875, -1.1875, -1.1875) → scale(1.1875, 1.1875, 1.1875) + // 2. Removed the translate(-0.5, 0, -0.5) that followed the scale + // Additionally, 26.1's submitSkull no longer applies translate(0.5,0,0.5) + // + scale(-1,-1,1) internally (those were in 1.21.11's submitSkull). + // + // Our renderCustomSkull was designed for the 1.21.11 poseStack state, which + // had: scale(1.1875,-1.1875,-1.1875) * translate(-0.5,0,-0.5). + // We restore that here by flipping Y/Z and adding back the translate. + boolean fromCustomHeadLayer = imaginefunutils$isCalledFromCustomHeadLayer(); + if (fromCustomHeadLayer) { + poseStack.pushPose(); + poseStack.scale(1.0F, -1.0F, -1.0F); + poseStack.translate(-0.5F, 0.0F, -0.5F); + } + // In 1.21.11, callers 2/3/4 always passed direction=null, yaw=180.0F boolean success = PlayerHeadRenderer.render( texture, @@ -179,11 +175,25 @@ private static void onStaticRender( 180.0F ); + if (fromCustomHeadLayer) { + poseStack.popPose(); + } + if (success) { ci.cancel(); } } + @Unique + private static boolean imaginefunutils$isCalledFromCustomHeadLayer() { + for (StackTraceElement e : Thread.currentThread().getStackTrace()) { + if (e.getClassName().endsWith("CustomHeadLayer")) { + return true; + } + } + return false; + } + /** * Extract the Y-axis rotation angle (yaw) from the transformation matrix. * The 3x3 part is R_Y(yaw) * S(-1,-1,1). We undo S(-1,-1,1) to get diff --git a/src/client/java/net/imaginefun/mixins/client/TitleScreenMixin.java b/src/client/java/net/imaginefun/mixins/client/TitleScreenMixin.java deleted file mode 100644 index 65047f3..0000000 --- a/src/client/java/net/imaginefun/mixins/client/TitleScreenMixin.java +++ /dev/null @@ -1,60 +0,0 @@ -package net.imaginefun.mixins.client; - -import net.imaginefun.ImaginefunUtilsConstants; -import net.imaginefun.gui.ImagineFunButton; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.components.AbstractWidget; -import net.minecraft.client.gui.components.Renderable; -import net.minecraft.client.gui.screens.ConnectScreen; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.gui.screens.TitleScreen; -import net.minecraft.client.multiplayer.ServerData; -import net.minecraft.client.multiplayer.resolver.ServerAddress; -import net.minecraft.network.chat.Component; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; - -@Mixin(TitleScreen.class) -public abstract class TitleScreenMixin extends Screen { - - protected TitleScreenMixin(Component title) { - super(title); - } - - @Inject(method = "init", at = @At("TAIL")) - private void addImagineFunButton(CallbackInfo ci) { - Minecraft minecraft = Minecraft.getInstance(); - - int buttonWidth = 200; - int buttonHeight = 20; - int x = this.width / 2 - 100; - int y = this.height / 4 + 48 + 24; - - for (Renderable renderable : ((ScreenAccessor) this).imaginefunutils$getRenderables()) { - if (renderable instanceof AbstractWidget widget) { - if (widget.getY() >= y) { - widget.setY(widget.getY() + 24); - } - } - } - - ImagineFunButton joinButton = new ImagineFunButton( - x, y, buttonWidth, buttonHeight, - Component.literal("Join " + ImaginefunUtilsConstants.serverName), - button -> { - ServerData serverData = new ServerData( - ImaginefunUtilsConstants.serverName, - ImaginefunUtilsConstants.serverAddress, - ServerData.Type.OTHER - ); - serverData.setResourcePackStatus(ServerData.ServerPackStatus.ENABLED); - ServerAddress serverAddress = ServerAddress.parseString(ImaginefunUtilsConstants.serverAddress); - ConnectScreen.startConnecting((TitleScreen) (Object) this, minecraft, serverAddress, serverData, false, null); - } - ); - - this.addRenderableWidget(joinButton); - } -} diff --git a/src/client/resources/imaginefunutils.client.mixins.json b/src/client/resources/imaginefunutils.client.mixins.json index e1adf8f..e1b885a 100644 --- a/src/client/resources/imaginefunutils.client.mixins.json +++ b/src/client/resources/imaginefunutils.client.mixins.json @@ -6,11 +6,8 @@ "GameTestBlockHighlightRendererMixin", "RenderSetupAccessor", "RenderTypeAccessor", - "ScreenAccessor", "SkullBlockRendererMixin", - "SkinTextureDownloaderMixin", - "TextureBindingAccessor", - "TitleScreenMixin" +"TextureBindingAccessor" ], "injectors": { "defaultRequire": 1 diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 38f21b5..4132b87 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -32,7 +32,7 @@ "depends": { "fabricloader": ">=0.18.3", "minecraft": "~26.1", - "java": ">=21", + "java": ">=25", "fabric-api": "*" } } diff --git a/src/main/resources/imaginefunutils.accesswidener b/src/main/resources/imaginefunutils.accesswidener index ac80e8a..0b481ad 100644 --- a/src/main/resources/imaginefunutils.accesswidener +++ b/src/main/resources/imaginefunutils.accesswidener @@ -2,4 +2,3 @@ accessWidener v2 official accessible class net/minecraft/client/renderer/debug/GameTestBlockHighlightRenderer$Marker accessible class net/minecraft/client/renderer/rendertype/RenderSetup$TextureBinding -accessible method net/minecraft/client/renderer/texture/SkinTextureDownloader downloadSkin (Ljava/nio/file/Path;Ljava/lang/String;)Lcom/mojang/blaze3d/platform/NativeImage; From cb8d196bbb758ff6ed5de21df79893f0f240c33e Mon Sep 17 00:00:00 2001 From: weikengchen Date: Mon, 30 Mar 2026 02:43:15 +0800 Subject: [PATCH 08/12] remove hardcoded java home path from gradle.properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org.gradle.java.home was a local macOS path that breaks CI. The build already sets release=25 and VERSION_25 in build.gradle which is sufficient — the CI runner provides Java 25 via setup-java. Co-Authored-By: Claude Opus 4.6 (1M context) --- gradle.properties | 1 - 1 file changed, 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 0a70fb3..8bfd4d2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,5 @@ # Done to increase the memory available to gradle. org.gradle.jvmargs=-Xmx1G -org.gradle.java.home=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home org.gradle.parallel=true # IntelliJ IDEA is not yet fully compatible with configuration cache, see: https://github.com/FabricMC/fabric-loom/issues/1349 From 92b1ea671500b24ff26ec7d7d509677630f948d7 Mon Sep 17 00:00:00 2001 From: weikengchen Date: Mon, 30 Mar 2026 02:45:18 +0800 Subject: [PATCH 09/12] switch CI Java distribution from temurin to microsoft Temurin doesn't have Java 25 builds yet. Microsoft OpenJDK does. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d4511a1..2606ecd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: uses: actions/setup-java@v4 with: java-version: '25' - distribution: 'temurin' + distribution: 'microsoft' - name: make gradle wrapper executable run: chmod +x ./gradlew - name: build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 92a75d2..37e08bc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,7 @@ jobs: uses: actions/setup-java@v4 with: java-version: '25' - distribution: 'temurin' + distribution: 'microsoft' - name: make gradle wrapper executable run: chmod +x ./gradlew - name: build From 3bcb3f607efce02a3b81d1a8bcecd7e895010db2 Mon Sep 17 00:00:00 2001 From: weikengchen Date: Mon, 30 Mar 2026 02:49:06 +0800 Subject: [PATCH 10/12] extract title screen handler to separate file Move the ScreenEvents.AFTER_INIT handler from ImagineFunUtilsClient into gui/TitleScreenHandler for clarity. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 -- .../net/imaginefun/ImagineFunUtilsClient.java | 59 +---------------- .../imaginefun/gui/TitleScreenHandler.java | 65 +++++++++++++++++++ 3 files changed, 67 insertions(+), 61 deletions(-) create mode 100644 src/client/java/net/imaginefun/gui/TitleScreenHandler.java diff --git a/.gitignore b/.gitignore index 7e48d8f..c476faf 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,3 @@ hs_err_*.log replay_*.log *.hprof *.jfr - -# local scripts - -build-and-deploy.sh diff --git a/src/client/java/net/imaginefun/ImagineFunUtilsClient.java b/src/client/java/net/imaginefun/ImagineFunUtilsClient.java index 977a081..6d28224 100644 --- a/src/client/java/net/imaginefun/ImagineFunUtilsClient.java +++ b/src/client/java/net/imaginefun/ImagineFunUtilsClient.java @@ -3,20 +3,12 @@ import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; -import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; -import net.fabricmc.fabric.api.client.screen.v1.Screens; import net.fabricmc.loader.api.FabricLoader; -import net.imaginefun.gui.ImagineFunButton; +import net.imaginefun.gui.TitleScreenHandler; import net.imaginefun.networking.ClientCustomPacketListener; import net.imaginefun.networking.ClientCustomPacketListenerImpl; import net.imaginefun.networking.HandshakePayload; import net.imaginefun.servers.ServerListPopulator; -import net.minecraft.client.gui.components.AbstractWidget; -import net.minecraft.client.gui.screens.ConnectScreen; -import net.minecraft.client.gui.screens.TitleScreen; -import net.minecraft.client.multiplayer.ServerData; -import net.minecraft.client.multiplayer.resolver.ServerAddress; -import net.minecraft.network.chat.Component; public class ImagineFunUtilsClient implements ClientModInitializer { @@ -35,54 +27,7 @@ public void onInitializeClient() { ClientPlayNetworking.send(new HandshakePayload(version)); }); - ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> { - if (!(screen instanceof TitleScreen)) return; - - // Screens.getWidgets() is the Fabric API-provided modifiable widget list. - // Adding to it is the correct way to register widgets from outside a Screen - // subclass (addRenderableWidget is protected). This is the same approach - // used by ModMenu. - var widgets = Screens.getWidgets(screen); - - // Find the Singleplayer button dynamically so we anchor to its actual - // position after all other mods (e.g. ModMenu) have already shifted things - // in their own AFTER_INIT or @Inject TAIL handlers. - int singleplayerIndex = -1; - AbstractWidget singleplayerButton = null; - String singleplayerText = Component.translatable("menu.singleplayer").getString(); - for (int i = 0; i < widgets.size(); i++) { - AbstractWidget widget = widgets.get(i); - if (singleplayerText.equals(widget.getMessage().getString())) { - singleplayerIndex = i; - singleplayerButton = widget; - break; - } - } - if (singleplayerButton == null) return; - - int y = singleplayerButton.getY() + 24; - int x = screen.width / 2 - 100; - - for (AbstractWidget widget : widgets) { - if (widget != singleplayerButton && widget.getY() >= y) { - widget.setY(widget.getY() + 24); - } - } - - widgets.add(singleplayerIndex + 1, new ImagineFunButton( - x, y, 200, 20, - Component.literal("Join " + ImaginefunUtilsConstants.serverName), - button -> { - ServerData serverData = new ServerData( - ImaginefunUtilsConstants.serverName, - ImaginefunUtilsConstants.serverAddress, - ServerData.Type.OTHER - ); - serverData.setResourcePackStatus(ServerData.ServerPackStatus.ENABLED); - ConnectScreen.startConnecting(screen, client, ServerAddress.parseString(ImaginefunUtilsConstants.serverAddress), serverData, false, null); - } - )); - }); + TitleScreenHandler.register(); } diff --git a/src/client/java/net/imaginefun/gui/TitleScreenHandler.java b/src/client/java/net/imaginefun/gui/TitleScreenHandler.java new file mode 100644 index 0000000..a0aa669 --- /dev/null +++ b/src/client/java/net/imaginefun/gui/TitleScreenHandler.java @@ -0,0 +1,65 @@ +package net.imaginefun.gui; + +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.Screens; +import net.imaginefun.ImaginefunUtilsConstants; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.ConnectScreen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.client.multiplayer.resolver.ServerAddress; +import net.minecraft.network.chat.Component; + +public class TitleScreenHandler { + + public static void register() { + ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> { + if (!(screen instanceof TitleScreen)) return; + + // Screens.getWidgets() is the Fabric API-provided modifiable widget list. + // Adding to it is the correct way to register widgets from outside a Screen + // subclass (addRenderableWidget is protected). This is the same approach + // used by ModMenu. + var widgets = Screens.getWidgets(screen); + + // Find the Singleplayer button dynamically so we anchor to its actual + // position after all other mods (e.g. ModMenu) have already shifted things + // in their own AFTER_INIT or @Inject TAIL handlers. + int singleplayerIndex = -1; + AbstractWidget singleplayerButton = null; + String singleplayerText = Component.translatable("menu.singleplayer").getString(); + for (int i = 0; i < widgets.size(); i++) { + AbstractWidget widget = widgets.get(i); + if (singleplayerText.equals(widget.getMessage().getString())) { + singleplayerIndex = i; + singleplayerButton = widget; + break; + } + } + if (singleplayerButton == null) return; + + int y = singleplayerButton.getY() + 24; + int x = screen.width / 2 - 100; + + for (AbstractWidget widget : widgets) { + if (widget != singleplayerButton && widget.getY() >= y) { + widget.setY(widget.getY() + 24); + } + } + + widgets.add(singleplayerIndex + 1, new ImagineFunButton( + x, y, 200, 20, + Component.literal("Join " + ImaginefunUtilsConstants.serverName), + button -> { + ServerData serverData = new ServerData( + ImaginefunUtilsConstants.serverName, + ImaginefunUtilsConstants.serverAddress, + ServerData.Type.OTHER + ); + serverData.setResourcePackStatus(ServerData.ServerPackStatus.ENABLED); + ConnectScreen.startConnecting(screen, client, ServerAddress.parseString(ImaginefunUtilsConstants.serverAddress), serverData, false, null); + } + )); + }); + } +} From 1f3177360d39e00be631c723f601c2be9eccf4c8 Mon Sep 17 00:00:00 2001 From: weikengchen Date: Mon, 30 Mar 2026 02:52:12 +0800 Subject: [PATCH 11/12] fix indentation in client mixins json Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/resources/imaginefunutils.client.mixins.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/resources/imaginefunutils.client.mixins.json b/src/client/resources/imaginefunutils.client.mixins.json index e1b885a..73ab88c 100644 --- a/src/client/resources/imaginefunutils.client.mixins.json +++ b/src/client/resources/imaginefunutils.client.mixins.json @@ -7,7 +7,7 @@ "RenderSetupAccessor", "RenderTypeAccessor", "SkullBlockRendererMixin", -"TextureBindingAccessor" + "TextureBindingAccessor" ], "injectors": { "defaultRequire": 1 From 508563bed420bf55690267aab01643e0e0259e4f Mon Sep 17 00:00:00 2001 From: weikengchen Date: Mon, 30 Mar 2026 02:59:17 +0800 Subject: [PATCH 12/12] use Transformation decomposition instead of raw matrix math Replace manual matrix extraction (Matrix3f scale-undoing, atan2 on matrix elements) with Transformation.translation() and leftRotation() accessors. The Transformation class already decomposes T*R*S cleanly, so we can read direction from the translation vector and yaw from the rotation quaternion directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../client/SkullBlockRendererMixin.java | 58 ++++++------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java index 12dde24..b894b04 100644 --- a/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java +++ b/src/client/java/net/imaginefun/mixins/client/SkullBlockRendererMixin.java @@ -3,9 +3,8 @@ import java.util.Map; -import org.joml.Matrix3f; -import org.joml.Matrix4fc; -import org.joml.Quaternionf; +import org.joml.Quaternionfc; +import org.joml.Vector3fc; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; @@ -15,6 +14,7 @@ import net.imaginefun.playerheads.PlayerHeadRenderer; import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Transformation; import net.minecraft.client.model.object.skull.SkullModelBase; import net.minecraft.client.renderer.SubmitNodeCollector; @@ -82,31 +82,26 @@ private void onSubmit( return; } - // Extract direction and yaw from state.transformation. - // The transformation matrix encodes: T * R_Y(-yaw) * S(-1,-1,1) - // where T is the direction-based translation and yaw is negated - // compared to 1.21.11's rotationDegrees. We negate extractYaw to match. - Matrix4fc M = state.transformation.getMatrix(); - float tx = M.m30(); - float ty = M.m31(); - - // Determine direction from translation: - // Ground skulls: ty == 0 -> direction = null - // Wall skulls: ty == 0.25 -> direction is non-null + // In 26.1, state.direction/rotationDegrees were replaced by state.transformation. + // Use Transformation's built-in decomposition to recover direction and yaw. + // The transformation is constructed as T * R_Y(-yaw) * S(-1,-1,1). + Transformation t = state.transformation; + Vector3fc translation = t.translation(); Direction direction; - float yaw; - if (Math.abs(ty - 0.25F) < 0.01F) { - // Wall skull — determine direction from translation offsets - float tz = M.m32(); - direction = imaginefunutils$directionFromTranslation(tx, tz); - yaw = -imaginefunutils$extractYaw(M); + if (Math.abs(translation.y() - 0.25F) < 0.01F) { + // Wall skull — direction from translation offsets + direction = imaginefunutils$directionFromTranslation(translation.x(), translation.z()); } else { - // Ground skull — direction is null + // Ground skull direction = null; - yaw = -imaginefunutils$extractYaw(M); } + // leftRotation is Axis.YP.rotationDegrees(-yaw), i.e. quaternion (0, sin(-yaw/2), 0, cos(-yaw/2)). + // Extract yaw: for a pure Y-rotation quaternion, angle = 2*atan2(q.y, q.w), negate to undo the sign. + Quaternionfc q = t.leftRotation(); + float yaw = -(float) Math.toDegrees(2.0 * Math.atan2(q.y(), q.w())); + boolean success = PlayerHeadRenderer.render( texture, poseStack, @@ -194,25 +189,6 @@ private static void onStaticRender( return false; } - /** - * Extract the Y-axis rotation angle (yaw) from the transformation matrix. - * The 3x3 part is R_Y(yaw) * S(-1,-1,1). We undo S(-1,-1,1) to get - * pure R_Y, then extract the angle. - */ - @Unique - private static float imaginefunutils$extractYaw(Matrix4fc M) { - // Get the 3x3 rotation+scale submatrix - Matrix3f rot = new Matrix3f(M); - // Undo S(-1,-1,1) by right-multiplying with S(-1,-1,1) (its own inverse) - rot.scale(-1, -1, 1); - // Now rot is pure R_Y(yaw). Extract yaw from atan2. - // R_Y = [[cos, 0, sin], [0, 1, 0], [-sin, 0, cos]] - // In JOML column-major: m00=cos, m20=sin, m02=-sin, m22=cos - float sin = rot.m20; - float cos = rot.m00; - return (float) Math.toDegrees(Math.atan2(sin, cos)); - } - /** * Determine the wall skull direction from the translation offsets. * Wall translations: 0.5 - direction.getStepX() * 0.25, 0.25, 0.5 - direction.getStepZ() * 0.25