From 0fc697d1dc0d43db7ab96a20376d35d9c9e508b0 Mon Sep 17 00:00:00 2001 From: Ukiyograin Date: Tue, 28 Jul 2026 17:02:38 +0800 Subject: [PATCH 01/10] fix(render): reduce HUD and nametag stutter --- .../top/fpsmaster/event/EventDispatcher.java | 2 +- .../fpsmaster/event/MethodHandleHandler.java | 43 +++++++ .../fpsmaster/features/GlobalListener.java | 2 +- .../features/impl/render/MotionBlur.java | 110 +++++++++--------- .../features/impl/utility/LevelTag.java | 71 +++-------- .../fpsmaster/forge/mixin/MixinRender.java | 6 +- .../top/fpsmaster/ui/custom/Component.java | 16 +++ .../ui/custom/ComponentsManager.java | 16 +++ 8 files changed, 153 insertions(+), 113 deletions(-) create mode 100644 src/main/java/top/fpsmaster/event/MethodHandleHandler.java diff --git a/src/main/java/top/fpsmaster/event/EventDispatcher.java b/src/main/java/top/fpsmaster/event/EventDispatcher.java index f3a48d83..9b1e9782 100644 --- a/src/main/java/top/fpsmaster/event/EventDispatcher.java +++ b/src/main/java/top/fpsmaster/event/EventDispatcher.java @@ -21,7 +21,7 @@ public static void registerListener(Object listener) { if (Event.class.isAssignableFrom(parameterType)) { Class eventType = (Class) parameterType; List listeners = eventListeners.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>()); - listeners.add(new ReflectHandler(listener, method)); + listeners.add(new MethodHandleHandler(listener, method)); } } } diff --git a/src/main/java/top/fpsmaster/event/MethodHandleHandler.java b/src/main/java/top/fpsmaster/event/MethodHandleHandler.java new file mode 100644 index 00000000..e22b514f --- /dev/null +++ b/src/main/java/top/fpsmaster/event/MethodHandleHandler.java @@ -0,0 +1,43 @@ +package top.fpsmaster.event; + +import top.fpsmaster.modules.logger.ClientLogger; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; + +public class MethodHandleHandler extends Handler { + private final MethodHandle handle; + + public MethodHandleHandler(Object listener, Method method) { + super(listener, method); + try { + method.setAccessible(true); + this.handle = MethodHandles.lookup() + .unreflect(method) + .bindTo(listener) + .asType(MethodType.methodType(void.class, Event.class)); + } catch (IllegalAccessException exception) { + throw new RuntimeException("Failed to create event handler for " + getLog(), exception); + } + } + + @Override + public void invoke(Event event) { + try { + handle.invokeExact(event); + } catch (Throwable throwable) { + ClientLogger.error("Error when invoking event " + getLog()); + if (throwable instanceof RuntimeException) { + throw (RuntimeException) throwable; + } + throw new RuntimeException(throwable); + } + } + + @Override + public String getLog() { + return listener.getClass().getSimpleName() + " -> " + method.getName(); + } +} diff --git a/src/main/java/top/fpsmaster/features/GlobalListener.java b/src/main/java/top/fpsmaster/features/GlobalListener.java index 6cb2e616..30c1517f 100644 --- a/src/main/java/top/fpsmaster/features/GlobalListener.java +++ b/src/main/java/top/fpsmaster/features/GlobalListener.java @@ -78,7 +78,7 @@ public void onRender(EventRender2D e) { if (ClientSettings.blur.getValue()) { StencilUtil.initStencilToWrite(); EventDispatcher.dispatchEvent(new EventShader()); - FPSMaster.componentsManager.draw((int) mouseX, (int) mouseY); + FPSMaster.componentsManager.drawBackgroundMasks(); StencilUtil.readStencilBuffer(1); KawaseBlur.renderBlur(3, 3); StencilUtil.uninitStencilBuffer(); diff --git a/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java b/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java index 72263f95..ffe7bc86 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java +++ b/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java @@ -175,62 +175,68 @@ public void onDisable() { public static void blur(float multiplier) { if (OpenGlHelper.isFramebufferEnabled()) { - ScaledResolution sr = new ScaledResolution(Minecraft.getMinecraft()); - int width = Minecraft.getMinecraft().getFramebuffer().framebufferWidth; - int height = Minecraft.getMinecraft().getFramebuffer().framebufferHeight; - // float division: with integer division the quads come up short by the - // remainder pixels, leaving unprocessed rows/columns at the bottom/right edge - float scaledWidth = (float) width / sr.getScaleFactor(); - float scaledHeight = (float) height / sr.getScaleFactor(); - + GL11.glPushAttrib(GL11.GL_ENABLE_BIT | GL11.GL_COLOR_BUFFER_BIT | GL11.GL_CURRENT_BIT | GL11.GL_TEXTURE_BIT); GlStateManager.matrixMode(GL11.GL_PROJECTION); GlStateManager.pushMatrix(); - GlStateManager.loadIdentity(); - GlStateManager.ortho(0.0, scaledWidth, scaledHeight, 0.0, 2000.0, 4000.0); GlStateManager.matrixMode(GL11.GL_MODELVIEW); GlStateManager.pushMatrix(); - GlStateManager.loadIdentity(); - GlStateManager.translate(0f, 0f, -2000f); - - blurBufferMain = checkFramebufferSizes(blurBufferMain, width, height); - blurBufferInto = checkFramebufferSizes(blurBufferInto, width, height); - - blurBufferInto.framebufferClear(); - blurBufferInto.bindFramebuffer(true); - - OpenGlHelper.glBlendFunc(770, 771, 0, 1); // GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA - GlStateManager.disableLighting(); - GlStateManager.disableFog(); - GlStateManager.disableBlend(); - - Minecraft.getMinecraft().getFramebuffer().bindFramebufferTexture(); - GlStateManager.color(1f, 1f, 1f, 1f); - drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, - 0f, 1f, 0f, 1f, 9728); - - GlStateManager.enableBlend(); - blurBufferMain.bindFramebufferTexture(); - GlStateManager.color(1f, 1f, 1f, multiplier / 10 - 0.1f); - drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, - 0f, 1f, 1f, 0f, 9728); - - Minecraft.getMinecraft().getFramebuffer().bindFramebuffer(true); - blurBufferInto.bindFramebufferTexture(); - GlStateManager.color(1f, 1f, 1f, 1f); - GlStateManager.enableBlend(); - OpenGlHelper.glBlendFunc(770, 771, 1, 771); - - drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, - 0f, 1f, 0f, 1f, 9728); - - Framebuffer tempBuff = blurBufferMain; - blurBufferMain = blurBufferInto; - blurBufferInto = tempBuff; - - GlStateManager.matrixMode(GL11.GL_PROJECTION); - GlStateManager.popMatrix(); - GlStateManager.matrixMode(GL11.GL_MODELVIEW); - GlStateManager.popMatrix(); + try { + ScaledResolution sr = new ScaledResolution(Minecraft.getMinecraft()); + int width = Minecraft.getMinecraft().getFramebuffer().framebufferWidth; + int height = Minecraft.getMinecraft().getFramebuffer().framebufferHeight; + // float division: with integer division the quads come up short by the + // remainder pixels, leaving unprocessed rows/columns at the bottom/right edge + float scaledWidth = (float) width / sr.getScaleFactor(); + float scaledHeight = (float) height / sr.getScaleFactor(); + + GlStateManager.matrixMode(GL11.GL_PROJECTION); + GlStateManager.loadIdentity(); + GlStateManager.ortho(0.0, scaledWidth, scaledHeight, 0.0, 2000.0, 4000.0); + GlStateManager.matrixMode(GL11.GL_MODELVIEW); + GlStateManager.loadIdentity(); + GlStateManager.translate(0f, 0f, -2000f); + + blurBufferMain = checkFramebufferSizes(blurBufferMain, width, height); + blurBufferInto = checkFramebufferSizes(blurBufferInto, width, height); + + blurBufferInto.framebufferClear(); + blurBufferInto.bindFramebuffer(true); + + OpenGlHelper.glBlendFunc(770, 771, 0, 1); // GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA + GlStateManager.disableLighting(); + GlStateManager.disableFog(); + GlStateManager.disableBlend(); + + Minecraft.getMinecraft().getFramebuffer().bindFramebufferTexture(); + GlStateManager.color(1f, 1f, 1f, 1f); + drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, + 0f, 1f, 0f, 1f, 9728); + + GlStateManager.enableBlend(); + blurBufferMain.bindFramebufferTexture(); + GlStateManager.color(1f, 1f, 1f, multiplier / 10 - 0.1f); + drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, + 0f, 1f, 1f, 0f, 9728); + + Minecraft.getMinecraft().getFramebuffer().bindFramebuffer(true); + blurBufferInto.bindFramebufferTexture(); + GlStateManager.color(1f, 1f, 1f, 1f); + GlStateManager.enableBlend(); + OpenGlHelper.glBlendFunc(770, 771, 1, 771); + + drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, + 0f, 1f, 0f, 1f, 9728); + + Framebuffer tempBuff = blurBufferMain; + blurBufferMain = blurBufferInto; + blurBufferInto = tempBuff; + } finally { + GlStateManager.matrixMode(GL11.GL_PROJECTION); + GlStateManager.popMatrix(); + GlStateManager.matrixMode(GL11.GL_MODELVIEW); + GlStateManager.popMatrix(); + GL11.glPopAttrib(); + } } } } diff --git a/src/main/java/top/fpsmaster/features/impl/utility/LevelTag.java b/src/main/java/top/fpsmaster/features/impl/utility/LevelTag.java index f60c85c1..bc217d33 100644 --- a/src/main/java/top/fpsmaster/features/impl/utility/LevelTag.java +++ b/src/main/java/top/fpsmaster/features/impl/utility/LevelTag.java @@ -21,6 +21,8 @@ import static top.fpsmaster.utils.core.Utility.mc; public class LevelTag extends Module { + private static final ResourceLocation MATE_ICON = new ResourceLocation("client/textures/mate.png"); + public static final BooleanSetting showSelf = new BooleanSetting("ShowSelf", true); public static final BooleanSetting betterFont = new BooleanSetting("BetterFont", false); public static final BooleanSetting fontShadow = new BooleanSetting("FontShadow", true); @@ -97,46 +99,32 @@ public static void renderName(Entity entityIn, String str, double x, double y, d if (bg.getValue()) { GlStateManager.disableTexture2D(); + Color color = backgroundColor.getColor(); + float red = color.getRed() / 255f; + float green = color.getGreen() / 255f; + float blue = color.getBlue() / 255f; + float alpha = color.getAlpha() / 255f; + worldRenderer.begin(7, DefaultVertexFormats.POSITION_COLOR); worldRenderer .pos(backgroundLeft, -1 + i, 0.0F) - .color( - backgroundColor.getColor().getRed() / 255f, - backgroundColor.getColor().getGreen() / 255f, - backgroundColor.getColor().getBlue() / 255f, - backgroundColor.getColor().getAlpha() / 255f - ) + .color(red, green, blue, alpha) .endVertex(); worldRenderer .pos(backgroundLeft, 8 + i, 0.0F) - .color( - backgroundColor.getColor().getRed() / 255f, - backgroundColor.getColor().getGreen() / 255f, - backgroundColor.getColor().getBlue() / 255f, - backgroundColor.getColor().getAlpha() / 255f - ) + .color(red, green, blue, alpha) .endVertex(); worldRenderer .pos(backgroundRight, 8 + i, 0.0F) - .color( - backgroundColor.getColor().getRed() / 255f, - backgroundColor.getColor().getGreen() / 255f, - backgroundColor.getColor().getBlue() / 255f, - backgroundColor.getColor().getAlpha() / 255f - ) + .color(red, green, blue, alpha) .endVertex(); worldRenderer .pos(backgroundRight, -1 + i, 0.0F) - .color( - backgroundColor.getColor().getRed() / 255f, - backgroundColor.getColor().getGreen() / 255f, - backgroundColor.getColor().getBlue() / 255f, - backgroundColor.getColor().getAlpha() / 255f - ) + .color(red, green, blue, alpha) .endVertex(); tessellator.draw(); @@ -150,7 +138,7 @@ public static void renderName(Entity entityIn, String str, double x, double y, d */ if (isMate) { Images.draw( - new ResourceLocation("client/textures/mate.png"), + MATE_ICON, iconX, i - 1, 8, @@ -158,39 +146,8 @@ public static void renderName(Entity entityIn, String str, double x, double y, d -1, true ); - - if (fontShadow.getValue()) { - fontRenderer.drawStringWithShadow( - str, - textX, - i, - 553648127 - ); - } else { - fontRenderer.drawString( - str, - textX, - i, - 553648127 - ); - } - } else { - if (fontShadow.getValue()) { - fontRenderer.drawStringWithShadow( - str, - textX, - i, - 553648127 - ); - } else { - fontRenderer.drawString( - str, - textX, - i, - 553648127 - ); - } } + fontRenderer.drawString(str, textX, i, 553648127); /* * 第二遍绘制:开启深度后绘制正常白色文字。 diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinRender.java b/src/main/java/top/fpsmaster/forge/mixin/MixinRender.java index bbacbae6..ea4f7153 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinRender.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinRender.java @@ -38,8 +38,10 @@ public void doRender(Entity entity, double x, double y, double z, float entityYa @Inject(method = "renderLivingLabel", at = @At("HEAD"), cancellable = true) protected void renderLivingLabel(Entity entityIn, String str, double x, double y, double z, int maxDistance, CallbackInfo ci) { - LevelTag.renderName(entityIn, str, x, y, z, maxDistance); - ci.cancel(); + if (LevelTag.using) { + LevelTag.renderName(entityIn, str, x, y, z, maxDistance); + ci.cancel(); + } } @Inject(method = "renderName", at = @At("HEAD"), cancellable = true) diff --git a/src/main/java/top/fpsmaster/ui/custom/Component.java b/src/main/java/top/fpsmaster/ui/custom/Component.java index f3f7b37d..acdd0548 100644 --- a/src/main/java/top/fpsmaster/ui/custom/Component.java +++ b/src/main/java/top/fpsmaster/ui/custom/Component.java @@ -22,6 +22,8 @@ import java.awt.*; public class Component { + private static final Color STENCIL_MASK_COLOR = new Color(255, 255, 255, 255); + private float dragX = 0f; private float dragY = 0f; @@ -114,6 +116,20 @@ public float[] getRealPosition(ScaledResolution sr) { return new float[]{rX, rY}; } + public void drawBlurMask(ScaledResolution sr) { + if (!mod.bg.getValue() || width <= 0f || height <= 0f) { + return; + } + float[] pos = getRealPosition(sr); + float scaledWidth = width * scale; + float scaledHeight = height * scale; + if (mod.rounded.getValue()) { + Rects.roundedImage(Math.round(pos[0] - 2), Math.round(pos[1]), Math.round(scaledWidth), Math.round(scaledHeight), mod.roundRadius.getValue().intValue(), STENCIL_MASK_COLOR); + } else { + Rects.fill(pos[0] - 2, pos[1], scaledWidth, scaledHeight, STENCIL_MASK_COLOR); + } + } + public void display(ScaledResolution sr, int mouseX, int mouseY) { float[] pos = getRealPosition(sr); float rX = pos[0]; diff --git a/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java b/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java index 354e22c1..1a9f6152 100644 --- a/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java +++ b/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java @@ -59,6 +59,22 @@ public Component getComponent(Class clazz) { .orElse(null); } + public void drawBackgroundMasks() { + GL11.glPushMatrix(); + net.minecraft.client.gui.ScaledResolution sr = new net.minecraft.client.gui.ScaledResolution(Utility.mc); + GuiScale.fixScale(); + components.forEach(component -> { + if (component.shouldDisplay()) { + try { + component.drawBlurMask(sr); + } catch (Exception e) { + ClientLogger.error("Failed to render component blur mask: " + component.mod.name); + } + } + }); + GL11.glPopMatrix(); + } + // Draw all components on the screen public void draw(int mouseX, int mouseY) { GL11.glPushMatrix(); From 48ce8d3c5a3811fd66147312fea152b88479c7ba Mon Sep 17 00:00:00 2001 From: Ukiyograin Date: Tue, 28 Jul 2026 21:34:58 +0800 Subject: [PATCH 02/10] feat(render): add chat avatars --- .../features/impl/render/ChatAvatarCache.java | 604 ++++++++++++++++++ .../features/impl/render/ChatAvatars.java | 65 ++ .../features/manager/ModuleManager.java | 1 + .../forge/mixin/MixinGuiNewChat.java | 39 +- .../assets/minecraft/client/lang/en_us.lang | 8 + .../assets/minecraft/client/lang/zh_cn.lang | 8 + 6 files changed, 714 insertions(+), 11 deletions(-) create mode 100644 src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java create mode 100644 src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java diff --git a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java new file mode 100644 index 00000000..259852ef --- /dev/null +++ b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java @@ -0,0 +1,604 @@ +package top.fpsmaster.features.impl.render; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.mojang.authlib.GameProfile; +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.AbstractClientPlayer; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.network.NetworkPlayerInfo; +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.client.resources.DefaultPlayerSkin; +import net.minecraft.client.renderer.texture.TextureManager; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.util.IChatComponent; +import net.minecraft.util.ResourceLocation; +import org.lwjgl.opengl.GL11; +import top.fpsmaster.FPSMaster; +import top.fpsmaster.modules.logger.ClientLogger; + +import javax.imageio.ImageIO; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URLEncoder; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static top.fpsmaster.utils.core.Utility.mc; + +public class ChatAvatarCache { + private static final ResourceLocation STEVE_SKIN = new ResourceLocation("textures/entity/steve.png"); + private static final Gson GSON = new Gson(); + private static final int MAX_CACHE_SIZE = 256; + private static final int REQUEST_TIMEOUT_MS = 2500; + private static final long IN_GAME_TTL_MS = 30_000L; + private static final long MOJANG_TTL_MS = 60L * 60L * 1000L; + private static final long MISS_TTL_MS = 2L * 60L * 1000L; + private static final long MIN_REQUEST_INTERVAL_MS = 750L; + private static final int MAX_PARALLEL_REQUESTS = 2; + + private static final LinkedHashMap CACHE = new LinkedHashMap(32, 0.75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + boolean remove = size() > MAX_CACHE_SIZE; + if (remove) { + deleteTexture(eldest.getValue()); + } + return remove; + } + }; + + private static final LinkedHashMap SENDER_CACHE = new LinkedHashMap(32, 0.75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_SIZE; + } + }; + + private static int activeRequests; + private static long lastRequestAt; + + private enum State { + READY, + LOADING, + MISS + } + + private static class AvatarEntry { + private State state; + private ResourceLocation texture; + private long expireAt; + private boolean dynamicTexture; + + private AvatarEntry(State state, ResourceLocation texture, long expireAt) { + this(state, texture, expireAt, false); + } + + private AvatarEntry(State state, ResourceLocation texture, long expireAt, boolean dynamicTexture) { + this.state = state; + this.texture = texture; + this.expireAt = expireAt; + this.dynamicTexture = dynamicTexture; + } + } + + private static class SenderEntry { + private String playerName; + private ResourceLocation texture; + private long expireAt; + + private SenderEntry(String playerName, ResourceLocation texture, long expireAt) { + this.playerName = playerName; + this.texture = texture; + this.expireAt = expireAt; + } + } + + private static class PlayerCandidate { + private String realName; + private String matchName; + private ResourceLocation texture; + + private PlayerCandidate(String realName, String matchName, ResourceLocation texture) { + this.realName = realName; + this.matchName = matchName; + this.texture = texture; + } + } + + public static ResourceLocation getAvatar(IChatComponent chatComponent, boolean mojangFallback) { + SenderEntry sender = findSender(chatComponent); + if (sender == null || sender.playerName == null || sender.playerName.trim().isEmpty()) { + return null; + } + if (sender.texture != null) { + put(sender.playerName, new AvatarEntry(State.READY, sender.texture, System.currentTimeMillis() + IN_GAME_TTL_MS)); + return sender.texture; + } + String playerName = sender.playerName; + + long now = System.currentTimeMillis(); + AvatarEntry entry = get(playerName); + if (entry != null && entry.expireAt > now && entry.state == State.READY) { + return entry.texture; + } + if (entry != null && entry.expireAt > now && entry.state != State.READY) { + return null; + } + + ResourceLocation inGameSkin = getInGameSkin(playerName); + if (inGameSkin != null) { + put(playerName, new AvatarEntry(State.READY, inGameSkin, now + IN_GAME_TTL_MS)); + return inGameSkin; + } + + if (mojangFallback && isValidPlayerName(playerName)) { + queueMojangLoad(playerName); + } else { + put(playerName, new AvatarEntry(State.MISS, null, now + MISS_TTL_MS)); + } + return null; + } + + public static void drawHead(ResourceLocation skin, int x, int y, int size, int alpha) { + if (skin == null || alpha <= 3) { + return; + } + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.getTextureManager() == null) { + return; + } + + boolean depthEnabled = GL11.glIsEnabled(GL11.GL_DEPTH_TEST); + GlStateManager.disableDepth(); + GlStateManager.enableBlend(); + GlStateManager.tryBlendFuncSeparate(770, 771, 1, 0); + GlStateManager.color(1.0F, 1.0F, 1.0F, Math.max(0.0F, Math.min(1.0F, alpha / 255.0F))); + minecraft.getTextureManager().bindTexture(skin); + Gui.drawScaledCustomSizeModalRect(x, y, 8.0F, 8.0F, 8, 8, size, size, 64.0F, 64.0F); + Gui.drawScaledCustomSizeModalRect(x, y, 40.0F, 8.0F, 8, 8, size, size, 64.0F, 64.0F); + GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F); + if (depthEnabled) { + GlStateManager.enableDepth(); + } else { + GlStateManager.disableDepth(); + } + } + + private static AvatarEntry get(String playerName) { + synchronized (CACHE) { + return CACHE.get(cacheKey(playerName)); + } + } + + private static void put(String playerName, AvatarEntry entry) { + synchronized (CACHE) { + String key = cacheKey(playerName); + AvatarEntry previous = CACHE.put(key, entry); + if (previous != entry) { + deleteTexture(previous); + } + } + } + + private static SenderEntry findSender(IChatComponent chatComponent) { + if (chatComponent == null) { + return null; + } + String text = chatComponent.getUnformattedText(); + if (text == null || text.trim().isEmpty()) { + return null; + } + String cacheKey = senderCacheKey(text); + long now = System.currentTimeMillis(); + synchronized (SENDER_CACHE) { + SenderEntry entry = SENDER_CACHE.get(cacheKey); + if (entry != null && entry.expireAt > now) { + return entry; + } + } + + PlayerCandidate player = findOnlinePlayer(text); + SenderEntry sender = player != null + ? new SenderEntry(player.realName, player.texture, now + 1_000L) + : new SenderEntry(extractLikelySenderName(text), null, now + 1_000L); + synchronized (SENDER_CACHE) { + SENDER_CACHE.put(cacheKey, sender); + } + return sender; + } + + private static PlayerCandidate findOnlinePlayer(String text) { + List candidates = getOnlinePlayers(); + if (candidates.isEmpty()) { + return null; + } + + String head = text.substring(0, Math.min(text.length(), 96)); + int delimiter = firstDelimiter(head); + String prefix = delimiter >= 0 ? head.substring(0, delimiter) : head; + PlayerCandidate best = findBestOnlinePlayer(prefix, candidates, true); + if (best != null) { + return best; + } + return delimiter >= 0 ? null : findBestOnlinePlayer(head, candidates, false); + } + + private static PlayerCandidate findBestOnlinePlayer(String text, List candidates, boolean preferRightmost) { + PlayerCandidate best = null; + int bestIndex = preferRightmost ? Integer.MIN_VALUE : Integer.MAX_VALUE; + int bestLength = -1; + for (PlayerCandidate candidate : candidates) { + if (candidate == null || candidate.matchName == null || candidate.matchName.isEmpty()) { + continue; + } + int index = indexOfPlayerName(text, candidate.matchName, preferRightmost); + if (index < 0) { + continue; + } + int length = candidate.matchName.length(); + if (preferRightmost) { + if (index > bestIndex || (index == bestIndex && length > bestLength)) { + bestIndex = index; + bestLength = length; + best = candidate; + } + } else if (index < bestIndex || (index == bestIndex && length > bestLength)) { + bestIndex = index; + bestLength = length; + best = candidate; + } + } + return best; + } + + private static List getOnlinePlayers() { + ArrayList candidates = new ArrayList<>(); + try { + if (mc != null && mc.getNetHandler() != null) { + Collection infos = mc.getNetHandler().getPlayerInfoMap(); + if (infos != null) { + for (NetworkPlayerInfo info : infos) { + if (info == null || info.getGameProfile() == null || info.getGameProfile().getName() == null) { + continue; + } + String realName = info.getGameProfile().getName(); + addCandidate(candidates, realName, realName, info.getLocationSkin()); + if (info.getDisplayName() != null) { + addCandidate(candidates, realName, info.getDisplayName().getUnformattedText(), info.getLocationSkin()); + } + } + } + } + } catch (Exception ignored) { + // Net handler data can disappear while changing servers. + } + + try { + if (mc != null && mc.theWorld != null && mc.theWorld.playerEntities != null) { + for (Object object : mc.theWorld.playerEntities) { + if (object instanceof EntityPlayer) { + EntityPlayer player = (EntityPlayer) object; + String realName = player.getName(); + ResourceLocation texture = player instanceof AbstractClientPlayer ? ((AbstractClientPlayer) player).getLocationSkin() : null; + addCandidate(candidates, realName, realName, texture); + if (player.getDisplayName() != null) { + addCandidate(candidates, realName, player.getDisplayName().getUnformattedText(), texture); + } + } + } + } + } catch (Exception ignored) { + // World player list can be mutated by the client thread during shutdown. + } + return candidates; + } + + private static void addCandidate(List candidates, String realName, String matchName, ResourceLocation texture) { + if (realName == null || matchName == null || matchName.trim().isEmpty()) { + return; + } + String trimmed = matchName.trim(); + for (PlayerCandidate candidate : candidates) { + if (candidate.realName.equalsIgnoreCase(realName) && candidate.matchName.equalsIgnoreCase(trimmed)) { + return; + } + } + candidates.add(new PlayerCandidate(realName, trimmed, texture)); + } + + private static int indexOfPlayerName(String text, String playerName, boolean rightmost) { + String lowerText = text.toLowerCase(Locale.ROOT); + String lowerName = playerName.toLowerCase(Locale.ROOT); + int best = -1; + int from = 0; + while (from < lowerText.length()) { + int index = lowerText.indexOf(lowerName, from); + if (index < 0) { + break; + } + int before = index - 1; + int after = index + lowerName.length(); + boolean beforeOk = before < 0 || !isNameChar(lowerText.charAt(before)); + boolean afterOk = after >= lowerText.length() || !isNameChar(lowerText.charAt(after)); + if (beforeOk && afterOk) { + best = index; + if (!rightmost) { + return best; + } + } + from = index + 1; + } + return best; + } + + private static String extractLikelySenderName(String text) { + int delimiter = firstDelimiter(text); + if (delimiter < 0) { + return null; + } + String prefix = text.substring(0, Math.min(delimiter, 96)); + String[] tokens = prefix.split("[^A-Za-z0-9_]+"); + for (int i = tokens.length - 1; i >= 0; i--) { + String token = tokens[i]; + if (isValidPlayerName(token)) { + return token; + } + } + return null; + } + + private static int firstDelimiter(String text) { + int best = -1; + char[] delimiters = new char[]{':', ':', '>', '»', '›'}; + for (char delimiter : delimiters) { + int index = text.indexOf(delimiter); + if (index >= 0 && (best < 0 || index < best)) { + best = index; + } + } + return best; + } + + private static boolean isNameChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; + } + + private static boolean isValidPlayerName(String name) { + if (name == null || name.length() < 3 || name.length() > 16) { + return false; + } + for (int i = 0; i < name.length(); i++) { + if (!isNameChar(name.charAt(i))) { + return false; + } + } + return true; + } + + private static ResourceLocation getInGameSkin(String playerName) { + try { + if (mc != null && mc.getNetHandler() != null) { + Collection infos = mc.getNetHandler().getPlayerInfoMap(); + if (infos != null) { + for (NetworkPlayerInfo info : infos) { + if (info == null || info.getGameProfile() == null) { + continue; + } + GameProfile profile = info.getGameProfile(); + if (profile.getName() != null && profile.getName().equalsIgnoreCase(playerName)) { + return info.getLocationSkin(); + } + } + } + } + } catch (Exception ignored) { + // Fall through to world lookup. + } + + try { + if (mc != null && mc.theWorld != null) { + EntityPlayer player = mc.theWorld.getPlayerEntityByName(playerName); + if (player instanceof AbstractClientPlayer) { + return ((AbstractClientPlayer) player).getLocationSkin(); + } + } + } catch (Exception ignored) { + // Runtime player objects can be null while changing worlds. + } + return null; + } + + private static ResourceLocation getDefaultSkin(String playerName) { + UUID uuid = null; + if (playerName != null && !playerName.trim().isEmpty()) { + uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + playerName).getBytes(StandardCharsets.UTF_8)); + } + if (uuid == null) { + return STEVE_SKIN; + } + return DefaultPlayerSkin.getDefaultSkin(uuid); + } + + private static void queueMojangLoad(String playerName) { + long now = System.currentTimeMillis(); + synchronized (CACHE) { + AvatarEntry entry = CACHE.get(cacheKey(playerName)); + if (entry != null && entry.state == State.LOADING) { + return; + } + if (activeRequests >= MAX_PARALLEL_REQUESTS || now - lastRequestAt < MIN_REQUEST_INTERVAL_MS) { + deleteTexture(CACHE.put(cacheKey(playerName), new AvatarEntry(State.MISS, null, now + 5_000L))); + return; + } + activeRequests++; + lastRequestAt = now; + deleteTexture(CACHE.put(cacheKey(playerName), new AvatarEntry(State.LOADING, null, now + REQUEST_TIMEOUT_MS * 4L))); + } + + FPSMaster.async.runnable(() -> { + try { + ResourceLocation skin = loadMojangSkin(playerName); + if (skin != null) { + put(playerName, new AvatarEntry(State.READY, skin, System.currentTimeMillis() + MOJANG_TTL_MS, true)); + } else { + put(playerName, new AvatarEntry(State.MISS, null, System.currentTimeMillis() + MISS_TTL_MS)); + } + } catch (Exception exception) { + ClientLogger.warn("Failed to load chat avatar from Mojang API"); + put(playerName, new AvatarEntry(State.MISS, null, System.currentTimeMillis() + MISS_TTL_MS)); + } finally { + synchronized (CACHE) { + activeRequests = Math.max(0, activeRequests - 1); + } + } + }); + } + + private static ResourceLocation loadMojangSkin(String playerName) throws Exception { + String encodedName = URLEncoder.encode(playerName, "UTF-8"); + JsonObject userProfile = readJson("https://api.mojang.com/users/profiles/minecraft/" + encodedName); + if (userProfile == null || !userProfile.has("id") || userProfile.get("id").isJsonNull()) { + return null; + } + String playerId = userProfile.get("id").getAsString(); + if (playerId == null || playerId.trim().isEmpty()) { + return null; + } + String skinUrl = readSkinUrl(playerId.replace("-", "")); + if (skinUrl == null || skinUrl.trim().isEmpty()) { + return null; + } + BufferedImage image = readImage(skinUrl); + if (image == null || image.getWidth() < 64 || image.getHeight() < 32) { + return null; + } + BufferedImage skinImage = normalizeSkin(image); + final ResourceLocation[] result = new ResourceLocation[1]; + AtomicBoolean accepted = new AtomicBoolean(true); + CountDownLatch latch = new CountDownLatch(1); + Minecraft.getMinecraft().addScheduledTask(() -> { + try { + ResourceLocation texture = Minecraft.getMinecraft() + .getTextureManager() + .getDynamicTextureLocation("fpsmaster_chat_avatar_" + cacheKey(playerName), new DynamicTexture(skinImage)); + if (accepted.get()) { + result[0] = texture; + } else { + deleteTexture(texture); + } + } finally { + latch.countDown(); + } + }); + if (!latch.await(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + accepted.set(false); + return null; + } + return result[0]; + } + + private static String readSkinUrl(String playerId) throws Exception { + JsonObject profile = readJson("https://sessionserver.mojang.com/session/minecraft/profile/" + playerId); + if (profile == null) { + return null; + } + JsonArray properties = profile.getAsJsonArray("properties"); + if (properties == null) { + return null; + } + for (JsonElement element : properties) { + JsonObject property = element.getAsJsonObject(); + if (!"textures".equals(property.get("name").getAsString()) || !property.has("value")) { + continue; + } + String decoded = new String(Base64.getDecoder().decode(property.get("value").getAsString()), StandardCharsets.UTF_8); + JsonObject decodedJson = GSON.fromJson(decoded, JsonObject.class); + if (decodedJson == null || !decodedJson.has("textures") || !decodedJson.get("textures").isJsonObject()) { + continue; + } + JsonObject textures = decodedJson.getAsJsonObject("textures"); + if (textures.has("SKIN") && textures.get("SKIN").isJsonObject()) { + JsonObject skin = textures.getAsJsonObject("SKIN"); + if (skin.has("url") && !skin.get("url").isJsonNull()) { + return skin.get("url").getAsString(); + } + } + } + return null; + } + + private static JsonObject readJson(String url) throws Exception { + URLConnection connection = new URL(url).openConnection(); + connection.setConnectTimeout(REQUEST_TIMEOUT_MS); + connection.setReadTimeout(REQUEST_TIMEOUT_MS); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + return GSON.fromJson(reader, JsonObject.class); + } + } + + private static BufferedImage readImage(String url) throws Exception { + URLConnection connection = new URL(url).openConnection(); + connection.setConnectTimeout(REQUEST_TIMEOUT_MS); + connection.setReadTimeout(REQUEST_TIMEOUT_MS); + try (InputStream inputStream = connection.getInputStream()) { + return ImageIO.read(inputStream); + } + } + + private static BufferedImage normalizeSkin(BufferedImage source) { + BufferedImage converted = new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = converted.createGraphics(); + try { + graphics.drawImage(source, 0, 0, Math.min(source.getWidth(), 64), Math.min(source.getHeight(), 64), null); + } finally { + graphics.dispose(); + } + return converted; + } + + private static void deleteTexture(AvatarEntry entry) { + if (entry != null && entry.dynamicTexture) { + deleteTexture(entry.texture); + } + } + + private static void deleteTexture(ResourceLocation texture) { + if (texture == null) { + return; + } + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.getTextureManager() == null) { + return; + } + TextureManager textureManager = minecraft.getTextureManager(); + minecraft.addScheduledTask(() -> textureManager.deleteTexture(texture)); + } + + private static String cacheKey(String playerName) { + return playerName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]", "_"); + } + + private static String senderCacheKey(String text) { + String normalized = text.substring(0, Math.min(text.length(), 96)).toLowerCase(Locale.ROOT); + return Integer.toHexString(normalized.hashCode()); + } +} diff --git a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java new file mode 100644 index 00000000..2f7bc068 --- /dev/null +++ b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java @@ -0,0 +1,65 @@ +package top.fpsmaster.features.impl.render; + +import top.fpsmaster.features.manager.Category; +import top.fpsmaster.features.manager.Module; +import top.fpsmaster.features.settings.impl.BooleanSetting; +import top.fpsmaster.features.settings.impl.NumberSetting; +import net.minecraft.util.IChatComponent; +import net.minecraft.util.ResourceLocation; + +public class ChatAvatars extends Module { + public static boolean using = false; + + public static final BooleanSetting mojangFallback = new BooleanSetting("MojangFallback", false); + public static final NumberSetting size = new NumberSetting("Size", 8, 6, 9, 1); + public static final NumberSetting gap = new NumberSetting("Gap", 4, 1, 8, 1); + public static final NumberSetting offsetX = new NumberSetting("OffsetX", 0, -8, 8, 1); + public static final NumberSetting offsetY = new NumberSetting("OffsetY", 1, -4, 6, 1); + + public ChatAvatars() { + super("ChatAvatars", Category.RENDER); + addSettings(mojangFallback, size, gap, offsetX, offsetY); + } + + @Override + public void onEnable() { + super.onEnable(); + using = true; + } + + @Override + public void onDisable() { + super.onDisable(); + using = false; + } + + public static boolean isUsing() { + return using; + } + + public static int getAvatarSize() { + return Math.max(6, Math.min(9, size.getValue().intValue())); + } + + public static int getChatOffset() { + if (!isUsing()) { + return 0; + } + return getAvatarSize() + Math.max(1, Math.min(8, gap.getValue().intValue())); + } + + public static int getOffsetX() { + return offsetX.getValue().intValue(); + } + + public static int getOffsetY() { + return offsetY.getValue().intValue(); + } + + public static ResourceLocation getAvatar(IChatComponent chatComponent) { + if (!isUsing()) { + return null; + } + return ChatAvatarCache.getAvatar(chatComponent, mojangFallback.getValue()); + } +} diff --git a/src/main/java/top/fpsmaster/features/manager/ModuleManager.java b/src/main/java/top/fpsmaster/features/manager/ModuleManager.java index ae5b47a2..11ae43b6 100644 --- a/src/main/java/top/fpsmaster/features/manager/ModuleManager.java +++ b/src/main/java/top/fpsmaster/features/manager/ModuleManager.java @@ -90,6 +90,7 @@ public void init() { modules.add(new ItemPhysics()); modules.add(new MinimizedBobbing()); modules.add(new MoreParticles()); + modules.add(new ChatAvatars()); modules.add(new FPSDisplay()); modules.add(new ArmorDisplay()); modules.add(new BetterChat()); diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java index f4b72112..613d0ec3 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java @@ -8,11 +8,14 @@ import net.minecraft.util.ChatComponentText; import net.minecraft.util.IChatComponent; import net.minecraft.util.MathHelper; +import net.minecraft.util.ResourceLocation; import org.spongepowered.asm.mixin.*; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Redirect; import top.fpsmaster.FPSMaster; import top.fpsmaster.features.impl.interfaces.BetterChat; +import top.fpsmaster.features.impl.render.ChatAvatarCache; +import top.fpsmaster.features.impl.render.ChatAvatars; import java.awt.*; import java.util.List; @@ -67,8 +70,9 @@ public void drawChat(int updateCounter) { float g = this.getChatScale(); int k = MathHelper.ceiling_float_int((float) this.getChatWidth() / g); + int chatAvatarOffset = ChatAvatars.getChatOffset(); GlStateManager.pushMatrix(); - GlStateManager.translate(2.0F, 8.0F, 0.0F); + GlStateManager.translate(2.0F + chatAvatarOffset, 8.0F, 0.0F); GlStateManager.scale(g, g, 1.0F); int l = 0; @@ -94,7 +98,8 @@ public void drawChat(int updateCounter) { ++l; if (o > 3) { int q = -m * 9; - Gui.drawRect(-2, q - 9, k + 4, q, o / 2 << 24); + Gui.drawRect(-2 - chatAvatarOffset, q - 9, k + 4, q, o / 2 << 24); + drawChatAvatar(chatLine, -chatAvatarOffset + 1 + ChatAvatars.getOffsetX(), q - 8 + ChatAvatars.getOffsetY(), o); String string = chatLine.getChatComponent().getFormattedText(); GlStateManager.enableBlend(); mc.fontRendererObj.drawStringWithShadow(string, 0.0F, (float) (q - 8), 16777215 + (o << 24)); @@ -115,8 +120,8 @@ public void drawChat(int updateCounter) { if (r != n) { o = s > 0 ? 170 : 96; int p = this.isScrolled ? 13382451 : 3355562; - Gui.drawRect(0, -s, 2, -s - t, p + (o << 24)); - Gui.drawRect(2, -s, 1, -s - t, 13421772 + (o << 24)); + Gui.drawRect(-chatAvatarOffset, -s, 2 - chatAvatarOffset, -s - t, p + (o << 24)); + Gui.drawRect(2 - chatAvatarOffset, -s, 1 - chatAvatarOffset, -s - t, 13421772 + (o << 24)); } } @@ -132,8 +137,9 @@ public void drawChat(int updateCounter) { float g = this.getChatScale(); int k = MathHelper.ceiling_float_int((float) this.getChatWidth() / g); + int chatAvatarOffset = ChatAvatars.getChatOffset(); GlStateManager.pushMatrix(); - GlStateManager.translate(2.0F, 8.0F, 0.0F); + GlStateManager.translate(2.0F + chatAvatarOffset, 8.0F, 0.0F); GlStateManager.scale(g, g, 1.0F); int m; @@ -156,7 +162,8 @@ public void drawChat(int updateCounter) { if (alpha > 3) { int q = -m * 9; int alpha1 = (int) ((alpha / 255f) * module.backgroundColor.getColor().getAlpha()); - Gui.drawRect(-2, q - 8, k + 4, q + 1, Colors.alpha(module.backgroundColor.getColor(), alpha1).getRGB()); + Gui.drawRect(-2 - chatAvatarOffset, q - 8, k + 4, q + 1, Colors.alpha(module.backgroundColor.getColor(), alpha1).getRGB()); + drawChatAvatar(chatLine, -chatAvatarOffset + 1 + ChatAvatars.getOffsetX(), q - 8 + ChatAvatars.getOffsetY() + Math.round(6 - (alpha / 255f) * 6), alpha); String string = chatLine.getChatComponent().getFormattedText(); GlStateManager.enableBlend(); if (module.betterFont.getValue()) { @@ -176,8 +183,8 @@ public void drawChat(int updateCounter) { GlStateManager.translate(-3.0F, 0.0F, 0.0F); int r = j * m + j; if (r != 0) { - Gui.drawRect(0, 0, 2, 0, module.backgroundColor.getColor().getRGB()); - Gui.drawRect(2, 0, 1, 0, module.backgroundColor.getColor().getRGB()); + Gui.drawRect(-chatAvatarOffset, 0, 2 - chatAvatarOffset, 0, module.backgroundColor.getColor().getRGB()); + Gui.drawRect(2 - chatAvatarOffset, 0, 1 - chatAvatarOffset, 0, module.backgroundColor.getColor().getRGB()); } } @@ -201,7 +208,7 @@ public IChatComponent getChatComponent(int mouseX, int mouseY) { float f = this.getChatScale(); int j = mouseX / i - 2; int k = mouseY / i - 40; - j = MathHelper.floor_float((float) j / f); + j = MathHelper.floor_float((float) j / f) - ChatAvatars.getChatOffset(); k = MathHelper.floor_float((float) k / f); if (j >= 0 && k >= 0) { AtomicInteger l = new AtomicInteger(Math.min(this.getLineCount(), this.drawnChatLines.size())); @@ -245,10 +252,20 @@ public IChatComponent getChatComponent(int mouseX, int mouseY) { public List spilt(IChatComponent chatComponent, int i, FontRenderer chatcomponenttext, boolean l, boolean chatcomponenttext2){ BetterChat module = (BetterChat) FPSMaster.moduleManager.getModule(BetterChat.class); + int chatAvatarOffset = ChatAvatars.getChatOffset(); + int width = Math.max(20, i - chatAvatarOffset); if (BetterChat.using && module.betterFont.getValue()) { - return GuiUtilRenderComponents.splitText(chatComponent, i, FPSMaster.fontManager.s16, false, false); + return GuiUtilRenderComponents.splitText(chatComponent, width, FPSMaster.fontManager.s16, false, false); } else { - return GuiUtilRenderComponents.splitText(chatComponent, i, mc.fontRendererObj, false, false); + return GuiUtilRenderComponents.splitText(chatComponent, width, mc.fontRendererObj, false, false); + } + } + + @Unique + private void drawChatAvatar(ChatLine chatLine, int x, int y, int alpha) { + ResourceLocation avatar = ChatAvatars.getAvatar(chatLine.getChatComponent()); + if (avatar != null) { + ChatAvatarCache.drawHead(avatar, x, y, ChatAvatars.getAvatarSize(), alpha); } } diff --git a/src/main/resources/assets/minecraft/client/lang/en_us.lang b/src/main/resources/assets/minecraft/client/lang/en_us.lang index 28aba156..3eb9723d 100644 --- a/src/main/resources/assets/minecraft/client/lang/en_us.lang +++ b/src/main/resources/assets/minecraft/client/lang/en_us.lang @@ -213,6 +213,14 @@ betterchat.background=Show Background betterchat.foldmessage=Fold Message betterchat.copymessage=Copy Message +chatavatars=Chat Avatars +chatavatars.desc=Shows player heads next to chat messages +chatavatars.mojangfallback=Mojang Fallback +chatavatars.size=Avatar Size +chatavatars.gap=Avatar Gap +chatavatars.offsetx=Horizontal Offset +chatavatars.offsety=Vertical Offset + combodisplay=Combo Counter combodisplay.desc=Displays current combo count combodisplay.textcolor=Text Color diff --git a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang index d55f69bc..e5d3461d 100644 --- a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang +++ b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang @@ -213,6 +213,14 @@ betterchat.background=背景 betterchat.foldmessage=折叠消息 betterchat.copymessage=复制消息 +chatavatars=聊天头像 +chatavatars.desc=在聊天消息旁显示玩家头像 +chatavatars.mojangfallback=Mojang 备用加载 +chatavatars.size=头像大小 +chatavatars.gap=头像间距 +chatavatars.offsetx=水平偏移 +chatavatars.offsety=垂直偏移 + combodisplay=连击显示 combodisplay.desc=显示连击数 combodisplay.textcolor=文字颜色 From c42549d4a7e401836c446c462e7bba50cb4382a4 Mon Sep 17 00:00:00 2001 From: Ukiyograin Date: Tue, 28 Jul 2026 21:35:15 +0800 Subject: [PATCH 03/10] fix(font): preserve chat shadow offset --- src/main/java/top/fpsmaster/font/impl/StringCache.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/top/fpsmaster/font/impl/StringCache.java b/src/main/java/top/fpsmaster/font/impl/StringCache.java index ecc76a77..187d0a63 100644 --- a/src/main/java/top/fpsmaster/font/impl/StringCache.java +++ b/src/main/java/top/fpsmaster/font/impl/StringCache.java @@ -427,9 +427,11 @@ public int renderString(String str, float startX, float startY, int initialColor GlStateManager.disableBlend(); - // 文字位置像素对齐,避免纹理采样模糊 - startX = Math.round(startX); - startY = Math.round(startY); + // 文字位置像素对齐,避免纹理采样模糊;阴影 pass 保留半像素偏移,避免格式化粗体文本阴影偏移过大。 + if (!shadowFlag) { + startX = Math.round(startX); + startY = Math.round(startY); + } // 检查无效参数 if (str == null || str.isEmpty()) { From 5df2cc14bd023828ea8d88c0765d0c41b64dd1c7 Mon Sep 17 00:00:00 2001 From: Serendisand <2742754621@qq.com> Date: Wed, 29 Jul 2026 10:22:00 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E5=AE=8C=E6=88=90=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tasks.md b/docs/tasks.md index e3125871..c86f5724 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -40,7 +40,7 @@ FPSMaster v4将会在此列表任务大部分完成后发布 - [ ] 添加异步光照性能优化 - [ ] 添加自定义迷雾(颜色,距离等) - [ ] 为客户端的组件颜色选择添加彩色选项 -- [ ] 在聊天栏显示头像 +- [x] 在聊天栏显示头像 - [ ] HypixelMod(合并AutoGG,AutoPlay以及聊天栏实用功能等) - [ ] 添加其他版本的支持(1.21.4 等) - [ ] 允许将正版账号绑定到FPSMaster账号上,并通过正版验证自动登录 From 74602d15180462fc75dec4c65ea78e9de18b5829 Mon Sep 17 00:00:00 2001 From: Ukiyograin Date: Fri, 31 Jul 2026 19:10:41 +0800 Subject: [PATCH 05/10] fix(render): harden chat avatars and font shadows --- .../features/impl/render/ChatAvatarCache.java | 68 +++++++------- .../top/fpsmaster/font/impl/StringCache.java | 8 +- .../fpsmaster/font/impl/UFontRenderer.java | 12 +-- .../forge/mixin/MixinGuiNewChat.java | 32 +++++-- .../ui/screens/mainmenu/MainMenu.java | 88 +++++++++++++------ .../top/fpsmaster/utils/system/OSUtil.java | 11 +++ 6 files changed, 139 insertions(+), 80 deletions(-) diff --git a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java index 259852ef..510c9b98 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java +++ b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java @@ -54,6 +54,7 @@ public class ChatAvatarCache { private static final long MISS_TTL_MS = 2L * 60L * 1000L; private static final long MIN_REQUEST_INTERVAL_MS = 750L; private static final int MAX_PARALLEL_REQUESTS = 2; + private static final int MAX_SENDER_SEPARATOR_DISTANCE = 16; private static final LinkedHashMap CACHE = new LinkedHashMap(32, 0.75F, true) { @Override @@ -129,14 +130,16 @@ public static ResourceLocation getAvatar(IChatComponent chatComponent, boolean m if (sender == null || sender.playerName == null || sender.playerName.trim().isEmpty()) { return null; } + String playerName = sender.playerName; + long now = System.currentTimeMillis(); + AvatarEntry entry = get(playerName); if (sender.texture != null) { - put(sender.playerName, new AvatarEntry(State.READY, sender.texture, System.currentTimeMillis() + IN_GAME_TTL_MS)); + if (entry == null || entry.state != State.READY || entry.texture != sender.texture || entry.expireAt <= now) { + put(playerName, new AvatarEntry(State.READY, sender.texture, now + IN_GAME_TTL_MS)); + } return sender.texture; } - String playerName = sender.playerName; - long now = System.currentTimeMillis(); - AvatarEntry entry = get(playerName); if (entry != null && entry.expireAt > now && entry.state == State.READY) { return entry.texture; } @@ -193,7 +196,7 @@ private static void put(String playerName, AvatarEntry entry) { synchronized (CACHE) { String key = cacheKey(playerName); AvatarEntry previous = CACHE.put(key, entry); - if (previous != entry) { + if (previous != null && previous != entry && previous.texture != entry.texture) { deleteTexture(previous); } } @@ -233,35 +236,27 @@ private static PlayerCandidate findOnlinePlayer(String text) { } String head = text.substring(0, Math.min(text.length(), 96)); - int delimiter = firstDelimiter(head); - String prefix = delimiter >= 0 ? head.substring(0, delimiter) : head; - PlayerCandidate best = findBestOnlinePlayer(prefix, candidates, true); + PlayerCandidate best = findBestOnlinePlayer(head, candidates, true); if (best != null) { return best; } - return delimiter >= 0 ? null : findBestOnlinePlayer(head, candidates, false); + return firstDelimiter(head) < 0 ? findBestOnlinePlayer(head, candidates, false) : null; } - private static PlayerCandidate findBestOnlinePlayer(String text, List candidates, boolean preferRightmost) { + private static PlayerCandidate findBestOnlinePlayer(String text, List candidates, boolean requireChatSeparator) { PlayerCandidate best = null; - int bestIndex = preferRightmost ? Integer.MIN_VALUE : Integer.MAX_VALUE; + int bestIndex = -1; int bestLength = -1; for (PlayerCandidate candidate : candidates) { if (candidate == null || candidate.matchName == null || candidate.matchName.isEmpty()) { continue; } - int index = indexOfPlayerName(text, candidate.matchName, preferRightmost); + int index = indexOfSenderName(text, candidate.matchName, requireChatSeparator); if (index < 0) { continue; } int length = candidate.matchName.length(); - if (preferRightmost) { - if (index > bestIndex || (index == bestIndex && length > bestLength)) { - bestIndex = index; - bestLength = length; - best = candidate; - } - } else if (index < bestIndex || (index == bestIndex && length > bestLength)) { + if (index > bestIndex || (index == bestIndex && length > bestLength)) { bestIndex = index; bestLength = length; best = candidate; @@ -325,7 +320,7 @@ private static void addCandidate(List candidates, String realNa candidates.add(new PlayerCandidate(realName, trimmed, texture)); } - private static int indexOfPlayerName(String text, String playerName, boolean rightmost) { + private static int indexOfSenderName(String text, String playerName, boolean requireChatSeparator) { String lowerText = text.toLowerCase(Locale.ROOT); String lowerName = playerName.toLowerCase(Locale.ROOT); int best = -1; @@ -339,17 +334,24 @@ private static int indexOfPlayerName(String text, String playerName, boolean rig int after = index + lowerName.length(); boolean beforeOk = before < 0 || !isNameChar(lowerText.charAt(before)); boolean afterOk = after >= lowerText.length() || !isNameChar(lowerText.charAt(after)); - if (beforeOk && afterOk) { + if (beforeOk && afterOk && (!requireChatSeparator || hasChatSeparatorAfter(text, after))) { best = index; - if (!rightmost) { - return best; - } } from = index + 1; } return best; } + private static boolean hasChatSeparatorAfter(String text, int from) { + int limit = Math.min(text.length(), from + MAX_SENDER_SEPARATOR_DISTANCE); + for (int i = from; i < limit; i++) { + if (isChatDelimiter(text.charAt(i))) { + return true; + } + } + return false; + } + private static String extractLikelySenderName(String text) { int delimiter = firstDelimiter(text); if (delimiter < 0) { @@ -367,15 +369,16 @@ private static String extractLikelySenderName(String text) { } private static int firstDelimiter(String text) { - int best = -1; - char[] delimiters = new char[]{':', ':', '>', '»', '›'}; - for (char delimiter : delimiters) { - int index = text.indexOf(delimiter); - if (index >= 0 && (best < 0 || index < best)) { - best = index; + for (int i = 0; i < text.length(); i++) { + if (isChatDelimiter(text.charAt(i))) { + return i; } } - return best; + return -1; + } + + private static boolean isChatDelimiter(char character) { + return character == ':' || character == ':' || character == '>' || character == '»' || character == '›'; } private static boolean isNameChar(char c) { @@ -598,7 +601,6 @@ private static String cacheKey(String playerName) { } private static String senderCacheKey(String text) { - String normalized = text.substring(0, Math.min(text.length(), 96)).toLowerCase(Locale.ROOT); - return Integer.toHexString(normalized.hashCode()); + return text.substring(0, Math.min(text.length(), 96)).toLowerCase(Locale.ROOT); } } diff --git a/src/main/java/top/fpsmaster/font/impl/StringCache.java b/src/main/java/top/fpsmaster/font/impl/StringCache.java index 187d0a63..5bbeb506 100644 --- a/src/main/java/top/fpsmaster/font/impl/StringCache.java +++ b/src/main/java/top/fpsmaster/font/impl/StringCache.java @@ -427,11 +427,9 @@ public int renderString(String str, float startX, float startY, int initialColor GlStateManager.disableBlend(); - // 文字位置像素对齐,避免纹理采样模糊;阴影 pass 保留半像素偏移,避免格式化粗体文本阴影偏移过大。 - if (!shadowFlag) { - startX = Math.round(startX); - startY = Math.round(startY); - } + // 对齐两个绘制 pass,避免粗体字形在线性过滤下因半像素阴影产生模糊光晕。 + startX = Math.round(startX); + startY = Math.round(startY); // 检查无效参数 if (str == null || str.isEmpty()) { diff --git a/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java b/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java index d6ea8226..9a73eeef 100644 --- a/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java +++ b/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java @@ -91,14 +91,10 @@ public String trimStringToWidth(String text, float width) { } public String trimString(String text, float width, boolean reverse) { - StringBuilder stringbuilder = new StringBuilder(); - for (char c : text.toCharArray()) { - if (getStringWidth(stringbuilder.toString()) < width) - stringbuilder.append(c); - else - break; + if (text == null || text.isEmpty()) { + return ""; } - return stringbuilder.toString(); + return stringCache.trimStringToWidth(GlobalTextFilter.filter(text), width, reverse); } /** @@ -169,7 +165,7 @@ private int drawHighDensityString(String text, float x, float y, int color, bool } private int drawStringInternal(String text, float x, float y, int color, boolean dropShadow) { - return drawStringInternal(text, x, y, color, dropShadow, 0.5f); + return drawStringInternal(text, x, y, color, dropShadow, 1.0f); } private int drawStringInternal(String text, float x, float y, int color, boolean dropShadow, float shadowOffset) { diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java index 613d0ec3..8abc3f09 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java @@ -19,7 +19,8 @@ import java.awt.*; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.Map; +import java.util.WeakHashMap; import static top.fpsmaster.utils.core.Utility.mc; @@ -54,6 +55,9 @@ public abstract class MixinGuiNewChat { @Shadow @Final private List chatLines; + @Unique + private final Map fpsmaster$chatMessageSources = new WeakHashMap<>(); + /** * @author SuperSkidder * @reason betterchat @@ -129,7 +133,7 @@ public void drawChat(int updateCounter) { } } else { BetterChat module = (BetterChat) FPSMaster.moduleManager.getModule(BetterChat.class); - AtomicInteger i = new AtomicInteger(this.getLineCount()); + int i = this.getLineCount(); int j = drawnChatLines.size(); float f = mc.gameSettings.chatOpacity * 0.9F + 0.1F; if (j > 0) { @@ -145,7 +149,7 @@ public void drawChat(int updateCounter) { int m; int n; int o; - for (m = 0; m + this.scrollPos < drawnChatLines.size() && m < i.get(); ++m) { + for (m = 0; m + this.scrollPos < drawnChatLines.size() && m < i; ++m) { ChatLine chatLine = drawnChatLines.get(m + this.scrollPos); if (chatLine != null) { if (getChatOpen() && v1_8_9$isChatOpenAnimationNeed) { @@ -211,14 +215,14 @@ public IChatComponent getChatComponent(int mouseX, int mouseY) { j = MathHelper.floor_float((float) j / f) - ChatAvatars.getChatOffset(); k = MathHelper.floor_float((float) k / f); if (j >= 0 && k >= 0) { - AtomicInteger l = new AtomicInteger(Math.min(this.getLineCount(), this.drawnChatLines.size())); + int lineCount = Math.min(this.getLineCount(), this.drawnChatLines.size()); int fontHeight = mc.fontRendererObj.FONT_HEIGHT; BetterChat module = (BetterChat) FPSMaster.moduleManager.getModule(BetterChat.class); if (BetterChat.using && module.betterFont.getValue()) { fontHeight = FPSMaster.fontManager.s16.getHeight(); } - if (j <= MathHelper.floor_float((float) this.getChatWidth() / this.getChatScale()) && k < fontHeight * l.get() + l.get()) { + if (j <= MathHelper.floor_float((float) this.getChatWidth() / this.getChatScale()) && k < fontHeight * lineCount + lineCount) { int m = k / fontHeight + this.scrollPos; if (m >= 0 && m < this.drawnChatLines.size()) { ChatLine chatLine = this.drawnChatLines.get(m); @@ -254,16 +258,28 @@ public List spilt(IChatComponent chatComponent, int i, FontRende int chatAvatarOffset = ChatAvatars.getChatOffset(); int width = Math.max(20, i - chatAvatarOffset); + List lines; if (BetterChat.using && module.betterFont.getValue()) { - return GuiUtilRenderComponents.splitText(chatComponent, width, FPSMaster.fontManager.s16, false, false); + lines = GuiUtilRenderComponents.splitText(chatComponent, width, FPSMaster.fontManager.s16, false, false); } else { - return GuiUtilRenderComponents.splitText(chatComponent, width, mc.fontRendererObj, false, false); + lines = GuiUtilRenderComponents.splitText(chatComponent, width, mc.fontRendererObj, false, false); + } + fpsmaster$rememberChatMessageSource(chatComponent, lines); + return lines; + } + + @Unique + private void fpsmaster$rememberChatMessageSource(IChatComponent source, List lines) { + for (IChatComponent line : lines) { + fpsmaster$chatMessageSources.put(line, source); } } @Unique private void drawChatAvatar(ChatLine chatLine, int x, int y, int alpha) { - ResourceLocation avatar = ChatAvatars.getAvatar(chatLine.getChatComponent()); + IChatComponent line = chatLine.getChatComponent(); + IChatComponent source = fpsmaster$chatMessageSources.get(line); + ResourceLocation avatar = ChatAvatars.getAvatar(source != null ? source : line); if (avatar != null) { ChatAvatarCache.drawHead(avatar, x, y, ChatAvatars.getAvatarSize(), alpha); } diff --git a/src/main/java/top/fpsmaster/ui/screens/mainmenu/MainMenu.java b/src/main/java/top/fpsmaster/ui/screens/mainmenu/MainMenu.java index 474202a5..16c6a242 100644 --- a/src/main/java/top/fpsmaster/ui/screens/mainmenu/MainMenu.java +++ b/src/main/java/top/fpsmaster/ui/screens/mainmenu/MainMenu.java @@ -20,6 +20,7 @@ import top.fpsmaster.utils.render.draw.Rects; import top.fpsmaster.utils.render.gui.Backgrounds; import top.fpsmaster.utils.render.gui.ScaledGuiScreen; +import top.fpsmaster.utils.system.OSUtil; import javax.imageio.ImageIO; import java.awt.*; @@ -153,6 +154,10 @@ public void render(int mouseX, int mouseY, float partialTicks) { } public static void preloadPlayerSkinTexture() { + if (OSUtil.isAndroid()) { + playerSkinLoadFailed = true; + return; + } Minecraft minecraft = Minecraft.getMinecraft(); if (minecraft == null || minecraft.getSession() == null) { return; @@ -171,35 +176,66 @@ public static void preloadPlayerSkinTexture() { loadedSkinPlayerId = normalizedPlayerId; playerSkinTexture = null; playerSkinLoadFailed = false; - FPSMaster.async.runnable(() -> { + try { + FPSMaster.async.runnable(() -> loadPlayerSkinTexture(normalizedPlayerId)); + } catch (RuntimeException exception) { + ClientLogger.warn("Failed to schedule main menu player skin loading"); + fallbackToDefaultSkin(normalizedPlayerId); + SKIN_LOADING.set(false); + } + } + + private static void loadPlayerSkinTexture(String playerId) { + boolean registrationQueued = false; + try { + String skinUrl = readSkinUrl(playerId); + if (skinUrl == null || skinUrl.trim().isEmpty()) { + fallbackToDefaultSkin(playerId); + return; + } + BufferedImage skinImage = readImage(skinUrl); + if (skinImage == null || skinImage.getWidth() < 64 || skinImage.getHeight() < 32) { + fallbackToDefaultSkin(playerId); + return; + } + BufferedImage textureImage = ensureArgbImage(skinImage); + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.getTextureManager() == null) { + fallbackToDefaultSkin(playerId); + return; + } + registrationQueued = true; try { - String skinUrl = readSkinUrl(normalizedPlayerId); - if (skinUrl == null || skinUrl.trim().isEmpty()) { - fallbackToDefaultSkin(normalizedPlayerId); - return; - } - BufferedImage skinImage = readImage(skinUrl); - if (skinImage == null || skinImage.getWidth() < 64 || skinImage.getHeight() < 32) { - fallbackToDefaultSkin(normalizedPlayerId); - return; - } - BufferedImage textureImage = ensureArgbImage(skinImage); - Minecraft.getMinecraft().addScheduledTask(() -> { - if (!normalizedPlayerId.equals(loadedSkinPlayerId)) { - return; - } - playerSkinTexture = Minecraft.getMinecraft() - .getTextureManager() - .getDynamicTextureLocation("fpsmaster_player_skin_" + normalizedPlayerId, new DynamicTexture(textureImage)); - playerSkinLoadFailed = false; - }); - } catch (Exception exception) { - ClientLogger.warn("Failed to load main menu player skin from Mojang API"); - fallbackToDefaultSkin(normalizedPlayerId); - } finally { + minecraft.addScheduledTask(() -> registerPlayerSkinTexture(playerId, textureImage)); + } catch (RuntimeException exception) { + registrationQueued = false; + throw exception; + } + } catch (Exception exception) { + ClientLogger.warn("Failed to load main menu player skin from Mojang API"); + fallbackToDefaultSkin(playerId); + } finally { + if (!registrationQueued) { SKIN_LOADING.set(false); } - }); + } + } + + private static void registerPlayerSkinTexture(String playerId, BufferedImage textureImage) { + try { + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.getTextureManager() == null || !playerId.equals(loadedSkinPlayerId)) { + return; + } + playerSkinTexture = minecraft.getTextureManager() + .getDynamicTextureLocation("fpsmaster_player_skin_" + playerId, new DynamicTexture(textureImage)); + playerSkinLoadFailed = false; + } catch (RuntimeException exception) { + ClientLogger.warn("Failed to register main menu player skin texture"); + fallbackToDefaultSkin(playerId); + } finally { + SKIN_LOADING.set(false); + } } private static void fallbackToDefaultSkin(String playerId) { diff --git a/src/main/java/top/fpsmaster/utils/system/OSUtil.java b/src/main/java/top/fpsmaster/utils/system/OSUtil.java index 923c2032..67606fc8 100644 --- a/src/main/java/top/fpsmaster/utils/system/OSUtil.java +++ b/src/main/java/top/fpsmaster/utils/system/OSUtil.java @@ -1,5 +1,7 @@ package top.fpsmaster.utils.system; +import java.util.Locale; + public class OSUtil { public static boolean supportShader = true; @@ -24,6 +26,15 @@ public static boolean isWindows() { return System.getProperty("os.name").toLowerCase().contains("windows"); } + public static boolean isAndroid() { + return containsIgnoreCase(System.getProperty("os.version"), "android") + || containsIgnoreCase(System.getProperty("java.runtime.name"), "android"); + } + + private static boolean containsIgnoreCase(String value, String expected) { + return value != null && value.toLowerCase(Locale.ROOT).contains(expected); + } + public static boolean supportShader() { return supportShader; } From 244ed757c98107cb9be98a3a4e331666b7633ea8 Mon Sep 17 00:00:00 2001 From: Ukiyograin Date: Fri, 31 Jul 2026 19:16:17 +0800 Subject: [PATCH 06/10] fix(chat): avoid component hash crash --- .../top/fpsmaster/forge/mixin/MixinGuiNewChat.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java index 8abc3f09..ed72eeeb 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java @@ -18,9 +18,9 @@ import top.fpsmaster.features.impl.render.ChatAvatars; import java.awt.*; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; -import java.util.WeakHashMap; import static top.fpsmaster.utils.core.Utility.mc; @@ -56,7 +56,10 @@ public abstract class MixinGuiNewChat { @Shadow @Final private List chatLines; @Unique - private final Map fpsmaster$chatMessageSources = new WeakHashMap<>(); + private static final int FPSMASTER_MAX_CHAT_MESSAGE_SOURCES = 512; + + @Unique + private final Map fpsmaster$chatMessageSources = new IdentityHashMap<>(); /** * @author SuperSkidder @@ -270,6 +273,9 @@ public List spilt(IChatComponent chatComponent, int i, FontRende @Unique private void fpsmaster$rememberChatMessageSource(IChatComponent source, List lines) { + if (fpsmaster$chatMessageSources.size() >= FPSMASTER_MAX_CHAT_MESSAGE_SOURCES) { + fpsmaster$chatMessageSources.clear(); + } for (IChatComponent line : lines) { fpsmaster$chatMessageSources.put(line, source); } From 5a65c2aa10a40c80de34ea663cbe4f01675d0b5c Mon Sep 17 00:00:00 2001 From: Ukiyograin Date: Fri, 31 Jul 2026 19:25:39 +0800 Subject: [PATCH 07/10] fix(chat): resolve prefixed display names --- .../features/impl/render/ChatAvatarCache.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java index 510c9b98..6c58972d 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java +++ b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java @@ -55,6 +55,7 @@ public class ChatAvatarCache { private static final long MIN_REQUEST_INTERVAL_MS = 750L; private static final int MAX_PARALLEL_REQUESTS = 2; private static final int MAX_SENDER_SEPARATOR_DISTANCE = 16; + private static final int MIN_DISPLAY_NAME_SUFFIX_LENGTH = 3; private static final LinkedHashMap CACHE = new LinkedHashMap(32, 0.75F, true) { @Override @@ -240,9 +241,34 @@ private static PlayerCandidate findOnlinePlayer(String text) { if (best != null) { return best; } + best = findBestDisplayNameSuffix(head, candidates); + if (best != null) { + return best; + } return firstDelimiter(head) < 0 ? findBestOnlinePlayer(head, candidates, false) : null; } + private static PlayerCandidate findBestDisplayNameSuffix(String text, List candidates) { + String senderName = extractSenderDisplayName(text); + if (senderName.isEmpty()) { + return null; + } + + PlayerCandidate best = null; + int bestLength = 0; + for (PlayerCandidate candidate : candidates) { + if (candidate == null || candidate.matchName == null || candidate.matchName.isEmpty()) { + continue; + } + int suffixLength = commonSuffixLength(senderName, candidate.matchName); + if (suffixLength >= MIN_DISPLAY_NAME_SUFFIX_LENGTH && suffixLength > bestLength) { + best = candidate; + bestLength = suffixLength; + } + } + return best; + } + private static PlayerCandidate findBestOnlinePlayer(String text, List candidates, boolean requireChatSeparator) { PlayerCandidate best = null; int bestIndex = -1; @@ -352,6 +378,39 @@ private static boolean hasChatSeparatorAfter(String text, int from) { return false; } + private static String extractSenderDisplayName(String text) { + int delimiter = firstDelimiter(text); + if (delimiter < 0) { + return ""; + } + int start = delimiter; + while (start > 0 && Character.isWhitespace(text.charAt(start - 1))) { + start--; + } + int nameStart = start; + while (nameStart > 0 && !Character.isWhitespace(text.charAt(nameStart - 1)) && text.charAt(nameStart - 1) != ']') { + nameStart--; + } + return text.substring(nameStart, start).trim(); + } + + private static int commonSuffixLength(String first, String second) { + int firstIndex = first.length() - 1; + int secondIndex = second.length() - 1; + int length = 0; + while (firstIndex >= 0 && secondIndex >= 0) { + char firstChar = Character.toLowerCase(first.charAt(firstIndex)); + char secondChar = Character.toLowerCase(second.charAt(secondIndex)); + if (firstChar != secondChar) { + break; + } + length++; + firstIndex--; + secondIndex--; + } + return length; + } + private static String extractLikelySenderName(String text) { int delimiter = firstDelimiter(text); if (delimiter < 0) { From 9069607dbe04eabb417d503b8dbb67b53842d2a9 Mon Sep 17 00:00:00 2001 From: Ukiyograin Date: Fri, 31 Jul 2026 21:09:44 +0800 Subject: [PATCH 08/10] fix(chat): generalize avatar display matching --- .../features/impl/render/ChatAvatarCache.java | 78 ++++++++++--------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java index 6c58972d..687beb65 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java +++ b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java @@ -55,7 +55,7 @@ public class ChatAvatarCache { private static final long MIN_REQUEST_INTERVAL_MS = 750L; private static final int MAX_PARALLEL_REQUESTS = 2; private static final int MAX_SENDER_SEPARATOR_DISTANCE = 16; - private static final int MIN_DISPLAY_NAME_SUFFIX_LENGTH = 3; + private static final int MIN_DISPLAY_NAME_MATCH_LENGTH = 3; private static final LinkedHashMap CACHE = new LinkedHashMap(32, 0.75F, true) { @Override @@ -241,32 +241,31 @@ private static PlayerCandidate findOnlinePlayer(String text) { if (best != null) { return best; } - best = findBestDisplayNameSuffix(head, candidates); + best = findBestDisplayNameMatch(head, candidates); if (best != null) { return best; } return firstDelimiter(head) < 0 ? findBestOnlinePlayer(head, candidates, false) : null; } - private static PlayerCandidate findBestDisplayNameSuffix(String text, List candidates) { - String senderName = extractSenderDisplayName(text); - if (senderName.isEmpty()) { - return null; - } - + private static PlayerCandidate findBestDisplayNameMatch(String text, List candidates) { PlayerCandidate best = null; - int bestLength = 0; + int bestScore = 0; + boolean ambiguous = false; for (PlayerCandidate candidate : candidates) { if (candidate == null || candidate.matchName == null || candidate.matchName.isEmpty()) { continue; } - int suffixLength = commonSuffixLength(senderName, candidate.matchName); - if (suffixLength >= MIN_DISPLAY_NAME_SUFFIX_LENGTH && suffixLength > bestLength) { + int score = getDisplayNameMatchScore(text, candidate.matchName); + if (score > bestScore) { best = candidate; - bestLength = suffixLength; + bestScore = score; + ambiguous = false; + } else if (score == bestScore && score > 0 && best != null && !best.realName.equalsIgnoreCase(candidate.realName)) { + ambiguous = true; } } - return best; + return ambiguous ? null : best; } private static PlayerCandidate findBestOnlinePlayer(String text, List candidates, boolean requireChatSeparator) { @@ -378,37 +377,44 @@ private static boolean hasChatSeparatorAfter(String text, int from) { return false; } - private static String extractSenderDisplayName(String text) { - int delimiter = firstDelimiter(text); + private static int getDisplayNameMatchScore(String text, String displayName) { + String normalizedText = text.toLowerCase(Locale.ROOT); + String normalizedDisplayName = displayName.toLowerCase(Locale.ROOT); + int delimiter = lastDelimiterBefore(normalizedText, Math.min(normalizedText.length(), 96)); if (delimiter < 0) { - return ""; + return 0; } - int start = delimiter; - while (start > 0 && Character.isWhitespace(text.charAt(start - 1))) { - start--; + String senderPrefix = normalizedText.substring(0, delimiter).trim(); + if (senderPrefix.contains(normalizedDisplayName)) { + return normalizedDisplayName.length() + MIN_DISPLAY_NAME_MATCH_LENGTH; } - int nameStart = start; - while (nameStart > 0 && !Character.isWhitespace(text.charAt(nameStart - 1)) && text.charAt(nameStart - 1) != ']') { - nameStart--; + int longestMatch = longestCommonSubstringLength(senderPrefix, normalizedDisplayName); + return longestMatch >= MIN_DISPLAY_NAME_MATCH_LENGTH ? longestMatch : 0; + } + + private static int longestCommonSubstringLength(String first, String second) { + int[] lengths = new int[second.length() + 1]; + int longest = 0; + for (int firstIndex = 1; firstIndex <= first.length(); firstIndex++) { + for (int secondIndex = second.length(); secondIndex > 0; secondIndex--) { + if (first.charAt(firstIndex - 1) == second.charAt(secondIndex - 1)) { + lengths[secondIndex] = lengths[secondIndex - 1] + 1; + longest = Math.max(longest, lengths[secondIndex]); + } else { + lengths[secondIndex] = 0; + } + } } - return text.substring(nameStart, start).trim(); + return longest; } - private static int commonSuffixLength(String first, String second) { - int firstIndex = first.length() - 1; - int secondIndex = second.length() - 1; - int length = 0; - while (firstIndex >= 0 && secondIndex >= 0) { - char firstChar = Character.toLowerCase(first.charAt(firstIndex)); - char secondChar = Character.toLowerCase(second.charAt(secondIndex)); - if (firstChar != secondChar) { - break; + private static int lastDelimiterBefore(String text, int limit) { + for (int i = limit - 1; i >= 0; i--) { + if (isChatDelimiter(text.charAt(i))) { + return i; } - length++; - firstIndex--; - secondIndex--; } - return length; + return -1; } private static String extractLikelySenderName(String text) { From af83d112ed3df62df1c37b414806e8a60c07b4f0 Mon Sep 17 00:00:00 2001 From: Ukiyograin Date: Fri, 31 Jul 2026 21:57:12 +0800 Subject: [PATCH 09/10] fix(config): preserve malformed profile data --- .../modules/config/ConfigManager.java | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/main/java/top/fpsmaster/modules/config/ConfigManager.java b/src/main/java/top/fpsmaster/modules/config/ConfigManager.java index 347e86c8..78a70f70 100644 --- a/src/main/java/top/fpsmaster/modules/config/ConfigManager.java +++ b/src/main/java/top/fpsmaster/modules/config/ConfigManager.java @@ -29,6 +29,9 @@ import top.fpsmaster.utils.world.ItemsUtil; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.*; public class ConfigManager { @@ -281,8 +284,16 @@ public void loadConfig(String name) throws Exception { if (moduleJson == null || !moduleJson.has("settings")) { continue; } - module.set(moduleJson.get("enabled").getAsBoolean()); - module.key = moduleJson.get("key").getAsInt(); + if (moduleJson.has("enabled") && !moduleJson.get("enabled").isJsonNull()) { + module.set(moduleJson.get("enabled").getAsBoolean()); + } else { + ClientLogger.warn("Missing enabled field in module config: " + module.name); + } + if (moduleJson.has("key") && !moduleJson.get("key").isJsonNull()) { + module.key = moduleJson.get("key").getAsInt(); + } else { + ClientLogger.warn("Missing key field in module config: " + module.name); + } JsonObject settingsJson = moduleJson.getAsJsonObject("settings"); for (Setting setting : module.settings) { try { @@ -317,13 +328,21 @@ public void loadConfig(String name) throws Exception { ((BindSetting) setting).setValue(value.getAsInt()); } else if (setting instanceof MultipleItemSetting && "multiItem".equals(type)) { MultipleItemSetting multipleItemSetting = (MultipleItemSetting) setting; - multipleItemSetting.getValue().clear(); + ArrayList items = new ArrayList<>(); for (JsonElement itemElement : value.getAsJsonArray()) { JsonObject item = itemElement.getAsJsonObject(); int id = item.get("id").getAsInt(); int metadata = item.get("meta").getAsInt(); - multipleItemSetting.addItem(ItemsUtil.getItemStackWithMetadata(Item.getItemById(id), metadata)); + Item resolvedItem = Item.getItemById(id); + if (resolvedItem == null) { + ClientLogger.warn("Skipping unknown item id " + id + " in setting " + module.name + "/" + setting.name); + continue; + } + if (items.size() < MultipleItemSetting.MAX_CAPACITY) { + items.add(ItemsUtil.getItemStackWithMetadata(resolvedItem, metadata)); + } } + multipleItemSetting.setValue(items); } } } catch (Throwable throwable) { @@ -379,8 +398,14 @@ private JsonObject migrateConfigIfNeeded(String name, JsonObject json) throws Fi List migrationPath = resolveMigrationPath(currentVersion, SCHEMA_VERSION); if (migrationPath.isEmpty()) { - ClientLogger.warn("No config migration path from schema " + currentVersion + " to " + SCHEMA_VERSION + ", deleting " + name + ".json"); - deleteConfigFile(name); + File backupFile = backupConfigFile(name, currentVersion); + if (currentVersion > SCHEMA_VERSION) { + ClientLogger.warn("Config schema " + currentVersion + " is newer than supported schema " + SCHEMA_VERSION + + ", resetting " + name + ".json and preserving the original at " + backupFile.getName()); + } else { + ClientLogger.warn("No config migration path from schema " + currentVersion + " to " + SCHEMA_VERSION + + ", resetting " + name + ".json and preserving the original at " + backupFile.getName()); + } return null; } @@ -449,10 +474,15 @@ private void resetConfigToDefaults(String name) throws FileException, Exception loadConfig(name); } - private void deleteConfigFile(String name) throws FileException { + private File backupConfigFile(String name, int schemaVersion) throws FileException { File configFile = ConfigProfileUtils.getProfileFile(name); - ClientLogger.warn("Deleting config file: " + configFile.getAbsolutePath()); - ConfigProfileUtils.deleteConfigFile(name); + File backupFile = new File(configFile.getParentFile(), configFile.getName() + ".bak-schemaV" + schemaVersion); + try { + Files.copy(configFile.toPath(), backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException exception) { + throw new FileException("Failed to back up config file: " + configFile.getAbsolutePath(), exception); + } + return backupFile; } private void resetAllModulesToDefaults() { From 42384b1f03a1e7774930cfad57668e475f465507 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 31 Jul 2026 08:14:48 -0700 Subject: [PATCH 10/10] =?UTF-8?q?fix(chat):=20=E7=A7=BB=E9=99=A4=20Mojang?= =?UTF-8?q?=20API=20=E5=85=9C=E5=BA=95=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E8=AF=86=E5=88=AB=E4=B8=8E=E6=B8=B2=E6=9F=93=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聊天头像改为只使用原版皮肤管理器(tab list / 世界实体)已有的纹理, 删除整条 Mojang API 拉取链路:网络 I/O、DynamicTexture 生命周期、 CountDownLatch 跨线程等待与并发限流状态全部移除。 顺带根除:线程池拒绝策略只打日志不抛异常,导致 activeRequests 计数 永久泄漏、两次拒绝后 Mojang 兜底整局失效。 发送者识别: - 新增 ClickEvent 预检,优先从 /msg|/tell|/w 的 suggest/run command 中提取发送者,这是比文本启发式高一个量级的置信度信号 - 删除 3 字符最长公共子串模糊匹配,它会把 "Party > Notch" 匹配到 在线的 Party_Manager - 修正无分隔符系统消息的匹配方向:有分隔符取最右(跳过 [Guild] Rank 前缀),无分隔符取最左(否则 "Bob was slain by Alice" 会选中 Alice) - 词边界判定改为 Unicode 感知,避免 CJK 昵称紧贴正文时误判 - 剥离其他聊天 mod 添加的 [12:34] 时间戳前缀 渲染修复: - 点击命中:offset 属于未缩放空间,须在除以 chatScale 之前减掉, 否则聊天缩放 != 1 时点不中链接 - 聊天背景右边界同步内缩,否则面板比设定的聊天宽度更宽 - 换行行映射表改为 FIFO 淘汰,整表清空会抹掉在屏行的头像 - MotionBlur 去掉 glPushAttrib/glPopAttrib,改为通过 GlStateManager 推回状态;裸 popAttrib 会让驱动状态倒退到缓存背后,使后续 enableBlend()/color() 被当作命中缓存而跳过 - UFontRenderer.trimString 不再对文本做 NameProtect 过滤,其返回 长度被 TextField 当作原始串的索引使用 - 高密度字体路径的阴影偏移补齐为 actualDensityScale,此前传半值, 逆缩放后回落到 0.5px,正是像素对齐改动要消除的偏移 Co-Authored-By: Claude --- .../features/impl/render/ChatAvatarCache.java | 417 +++++------------- .../features/impl/render/ChatAvatars.java | 6 +- .../features/impl/render/MotionBlur.java | 11 +- .../fpsmaster/font/impl/UFontRenderer.java | 13 +- .../forge/mixin/MixinGuiNewChat.java | 31 +- .../assets/minecraft/client/lang/en_us.lang | 1 - .../assets/minecraft/client/lang/zh_cn.lang | 1 - 7 files changed, 162 insertions(+), 318 deletions(-) diff --git a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java index 687beb65..d75439e4 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java +++ b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java @@ -1,70 +1,43 @@ package top.fpsmaster.features.impl.render; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import com.mojang.authlib.GameProfile; import net.minecraft.client.Minecraft; import net.minecraft.client.entity.AbstractClientPlayer; import net.minecraft.client.gui.Gui; import net.minecraft.client.network.NetworkPlayerInfo; import net.minecraft.client.renderer.GlStateManager; -import net.minecraft.client.renderer.texture.DynamicTexture; -import net.minecraft.client.resources.DefaultPlayerSkin; -import net.minecraft.client.renderer.texture.TextureManager; import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.event.ClickEvent; import net.minecraft.util.IChatComponent; import net.minecraft.util.ResourceLocation; import org.lwjgl.opengl.GL11; -import top.fpsmaster.FPSMaster; -import top.fpsmaster.modules.logger.ClientLogger; - -import javax.imageio.ImageIO; -import java.awt.Graphics2D; -import java.awt.image.BufferedImage; -import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.URLEncoder; -import java.net.URL; -import java.net.URLConnection; -import java.nio.charset.StandardCharsets; + import java.util.ArrayList; -import java.util.Base64; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.UUID; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static top.fpsmaster.utils.core.Utility.mc; public class ChatAvatarCache { - private static final ResourceLocation STEVE_SKIN = new ResourceLocation("textures/entity/steve.png"); - private static final Gson GSON = new Gson(); private static final int MAX_CACHE_SIZE = 256; - private static final int REQUEST_TIMEOUT_MS = 2500; private static final long IN_GAME_TTL_MS = 30_000L; - private static final long MOJANG_TTL_MS = 60L * 60L * 1000L; private static final long MISS_TTL_MS = 2L * 60L * 1000L; - private static final long MIN_REQUEST_INTERVAL_MS = 750L; - private static final int MAX_PARALLEL_REQUESTS = 2; private static final int MAX_SENDER_SEPARATOR_DISTANCE = 16; - private static final int MIN_DISPLAY_NAME_MATCH_LENGTH = 3; + private static final String[] WHISPER_COMMAND_PREFIXES = {"/tell ", "/msg ", "/whisper ", "/w ", "/t "}; + private static final Pattern TIMESTAMP_PREFIX = + Pattern.compile("^(?:\\[\\d\\d:\\d\\d(?::\\d\\d)?(?: [AP]M)?]|<\\d\\d:\\d\\d>)\\s*"); + // Every texture here is owned by the vanilla skin manager (tab list / world entities), so the + // cache never allocates or frees GL textures of its own — eviction is a plain map removal. private static final LinkedHashMap CACHE = new LinkedHashMap(32, 0.75F, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { - boolean remove = size() > MAX_CACHE_SIZE; - if (remove) { - deleteTexture(eldest.getValue()); - } - return remove; + return size() > MAX_CACHE_SIZE; } }; @@ -75,12 +48,8 @@ protected boolean removeEldestEntry(Map.Entry eldest) { } }; - private static int activeRequests; - private static long lastRequestAt; - private enum State { READY, - LOADING, MISS } @@ -88,17 +57,11 @@ private static class AvatarEntry { private State state; private ResourceLocation texture; private long expireAt; - private boolean dynamicTexture; private AvatarEntry(State state, ResourceLocation texture, long expireAt) { - this(state, texture, expireAt, false); - } - - private AvatarEntry(State state, ResourceLocation texture, long expireAt, boolean dynamicTexture) { this.state = state; this.texture = texture; this.expireAt = expireAt; - this.dynamicTexture = dynamicTexture; } } @@ -126,7 +89,7 @@ private PlayerCandidate(String realName, String matchName, ResourceLocation text } } - public static ResourceLocation getAvatar(IChatComponent chatComponent, boolean mojangFallback) { + public static ResourceLocation getAvatar(IChatComponent chatComponent) { SenderEntry sender = findSender(chatComponent); if (sender == null || sender.playerName == null || sender.playerName.trim().isEmpty()) { return null; @@ -141,11 +104,8 @@ public static ResourceLocation getAvatar(IChatComponent chatComponent, boolean m return sender.texture; } - if (entry != null && entry.expireAt > now && entry.state == State.READY) { - return entry.texture; - } - if (entry != null && entry.expireAt > now && entry.state != State.READY) { - return null; + if (entry != null && entry.expireAt > now) { + return entry.state == State.READY ? entry.texture : null; } ResourceLocation inGameSkin = getInGameSkin(playerName); @@ -154,11 +114,9 @@ public static ResourceLocation getAvatar(IChatComponent chatComponent, boolean m return inGameSkin; } - if (mojangFallback && isValidPlayerName(playerName)) { - queueMojangLoad(playerName); - } else { - put(playerName, new AvatarEntry(State.MISS, null, now + MISS_TTL_MS)); - } + // Only players the vanilla skin manager already knows about get a head; an unresolved name + // is cached as a miss rather than fetched, so chat rendering never touches the network. + put(playerName, new AvatarEntry(State.MISS, null, now + MISS_TTL_MS)); return null; } @@ -195,11 +153,7 @@ private static AvatarEntry get(String playerName) { private static void put(String playerName, AvatarEntry entry) { synchronized (CACHE) { - String key = cacheKey(playerName); - AvatarEntry previous = CACHE.put(key, entry); - if (previous != null && previous != entry && previous.texture != entry.texture) { - deleteTexture(previous); - } + CACHE.put(cacheKey(playerName), entry); } } @@ -220,7 +174,12 @@ private static SenderEntry findSender(IChatComponent chatComponent) { } } - PlayerCandidate player = findOnlinePlayer(text); + // Highest-confidence signal first: vanilla and most chat plugins attach a "/msg " + // suggest-command to the sender's name specifically, which beats any text heuristic. + PlayerCandidate player = findByWhisperCommand(chatComponent); + if (player == null) { + player = findOnlinePlayer(text); + } SenderEntry sender = player != null ? new SenderEntry(player.realName, player.texture, now + 1_000L) : new SenderEntry(extractLikelySenderName(text), null, now + 1_000L); @@ -230,45 +189,83 @@ private static SenderEntry findSender(IChatComponent chatComponent) { return sender; } - private static PlayerCandidate findOnlinePlayer(String text) { - List candidates = getOnlinePlayers(); - if (candidates.isEmpty()) { + /** + * Resolves the sender from a whisper click event rather than from the rendered text. Vanilla's chat + * decorator — and most server chat plugins — hang a {@code /msg } suggest-command on the + * sender's name component, so when it is present it identifies the sender exactly. + */ + private static PlayerCandidate findByWhisperCommand(IChatComponent chatComponent) { + String name = findWhisperTarget(chatComponent); + if (name == null) { return null; } + return new PlayerCandidate(name, name, getInGameSkin(name)); + } - String head = text.substring(0, Math.min(text.length(), 96)); - PlayerCandidate best = findBestOnlinePlayer(head, candidates, true); - if (best != null) { - return best; - } - best = findBestDisplayNameMatch(head, candidates); - if (best != null) { - return best; + private static String findWhisperTarget(IChatComponent chatComponent) { + try { + // IChatComponent iterates itself plus all nested siblings. + for (IChatComponent part : chatComponent) { + if (part == null || part.getChatStyle() == null) { + continue; + } + String name = whisperTargetOf(part.getChatStyle().getChatClickEvent()); + if (name != null) { + return name; + } + } + } catch (Exception ignored) { + // Malformed component trees from servers must not break chat rendering. } - return firstDelimiter(head) < 0 ? findBestOnlinePlayer(head, candidates, false) : null; + return null; } - private static PlayerCandidate findBestDisplayNameMatch(String text, List candidates) { - PlayerCandidate best = null; - int bestScore = 0; - boolean ambiguous = false; - for (PlayerCandidate candidate : candidates) { - if (candidate == null || candidate.matchName == null || candidate.matchName.isEmpty()) { + private static String whisperTargetOf(ClickEvent click) { + if (click == null || click.getValue() == null) { + return null; + } + if (click.getAction() != ClickEvent.Action.SUGGEST_COMMAND + && click.getAction() != ClickEvent.Action.RUN_COMMAND) { + return null; + } + String command = click.getValue().trim(); + for (String prefix : WHISPER_COMMAND_PREFIXES) { + if (!command.regionMatches(true, 0, prefix, 0, prefix.length())) { continue; } - int score = getDisplayNameMatchScore(text, candidate.matchName); - if (score > bestScore) { - best = candidate; - bestScore = score; - ambiguous = false; - } else if (score == bestScore && score > 0 && best != null && !best.realName.equalsIgnoreCase(candidate.realName)) { - ambiguous = true; + String rest = command.substring(prefix.length()).trim(); + int space = rest.indexOf(' '); + if (space >= 0) { + rest = rest.substring(0, space); } + return isValidPlayerName(rest) ? rest : null; + } + return null; + } + + private static PlayerCandidate findOnlinePlayer(String text) { + List candidates = getOnlinePlayers(); + if (candidates.isEmpty()) { + return null; + } + + String head = stripTimestamp(text); + head = head.substring(0, Math.min(head.length(), 96)); + + // A name immediately followed by a chat delimiter is the sender. Prefer the rightmost such + // match so rank/guild prefixes ("[Guild] MVP Player > hi") don't win over the real name. + PlayerCandidate best = findBestOnlinePlayer(head, candidates, true, true); + if (best != null) { + return best; } - return ambiguous ? null : best; + // No delimiter anywhere means a system line ("Player joined the game"). Here the subject + // comes first, so take the leftmost match — rightmost would pick "Alice" out of + // "Bob was slain by Alice". + return firstDelimiter(head) < 0 ? findBestOnlinePlayer(head, candidates, false, false) : null; } - private static PlayerCandidate findBestOnlinePlayer(String text, List candidates, boolean requireChatSeparator) { + private static PlayerCandidate findBestOnlinePlayer(String text, List candidates, + boolean requireChatSeparator, boolean preferRightmost) { PlayerCandidate best = null; int bestIndex = -1; int bestLength = -1; @@ -276,12 +273,15 @@ private static PlayerCandidate findBestOnlinePlayer(String text, List bestIndex || (index == bestIndex && length > bestLength)) { + boolean better = best == null + || (preferRightmost ? index > bestIndex : index < bestIndex) + || (index == bestIndex && length > bestLength); + if (better) { bestIndex = index; bestLength = length; best = candidate; @@ -345,7 +345,7 @@ private static void addCandidate(List candidates, String realNa candidates.add(new PlayerCandidate(realName, trimmed, texture)); } - private static int indexOfSenderName(String text, String playerName, boolean requireChatSeparator) { + private static int indexOfSenderName(String text, String playerName, boolean requireChatSeparator, boolean preferRightmost) { String lowerText = text.toLowerCase(Locale.ROOT); String lowerName = playerName.toLowerCase(Locale.ROOT); int best = -1; @@ -357,10 +357,14 @@ private static int indexOfSenderName(String text, String playerName, boolean req } int before = index - 1; int after = index + lowerName.length(); - boolean beforeOk = before < 0 || !isNameChar(lowerText.charAt(before)); - boolean afterOk = after >= lowerText.length() || !isNameChar(lowerText.charAt(after)); + // Word-boundary guards on both sides: "tom" must not match inside "custom" or "tomato". + boolean beforeOk = before < 0 || !isWordChar(lowerText.charAt(before)); + boolean afterOk = after >= lowerText.length() || !isWordChar(lowerText.charAt(after)); if (beforeOk && afterOk && (!requireChatSeparator || hasChatSeparatorAfter(text, after))) { best = index; + if (!preferRightmost) { + return best; + } } from = index + 1; } @@ -377,44 +381,14 @@ private static boolean hasChatSeparatorAfter(String text, int from) { return false; } - private static int getDisplayNameMatchScore(String text, String displayName) { - String normalizedText = text.toLowerCase(Locale.ROOT); - String normalizedDisplayName = displayName.toLowerCase(Locale.ROOT); - int delimiter = lastDelimiterBefore(normalizedText, Math.min(normalizedText.length(), 96)); - if (delimiter < 0) { - return 0; - } - String senderPrefix = normalizedText.substring(0, delimiter).trim(); - if (senderPrefix.contains(normalizedDisplayName)) { - return normalizedDisplayName.length() + MIN_DISPLAY_NAME_MATCH_LENGTH; - } - int longestMatch = longestCommonSubstringLength(senderPrefix, normalizedDisplayName); - return longestMatch >= MIN_DISPLAY_NAME_MATCH_LENGTH ? longestMatch : 0; - } - - private static int longestCommonSubstringLength(String first, String second) { - int[] lengths = new int[second.length() + 1]; - int longest = 0; - for (int firstIndex = 1; firstIndex <= first.length(); firstIndex++) { - for (int secondIndex = second.length(); secondIndex > 0; secondIndex--) { - if (first.charAt(firstIndex - 1) == second.charAt(secondIndex - 1)) { - lengths[secondIndex] = lengths[secondIndex - 1] + 1; - longest = Math.max(longest, lengths[secondIndex]); - } else { - lengths[secondIndex] = 0; - } - } - } - return longest; - } - - private static int lastDelimiterBefore(String text, int limit) { - for (int i = limit - 1; i >= 0; i--) { - if (isChatDelimiter(text.charAt(i))) { - return i; - } - } - return -1; + /** + * Strips a leading clock stamp that another chat mod may have prepended, e.g. {@code [12:34] } or + * {@code <12:34> }. Without this the sender name is no longer at the head of the line and the + * delimiter-anchored match degrades. + */ + private static String stripTimestamp(String text) { + Matcher matcher = TIMESTAMP_PREFIX.matcher(text); + return matcher.lookingAt() ? text.substring(matcher.end()) : text; } private static String extractLikelySenderName(String text) { @@ -446,10 +420,19 @@ private static boolean isChatDelimiter(char character) { return character == ':' || character == ':' || character == '>' || character == '»' || character == '›'; } + /** Minecraft account-name alphabet — deliberately ASCII-only, used to validate a real username. */ private static boolean isNameChar(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; } + /** + * Word-boundary alphabet for match guarding. Unlike {@link #isNameChar} this is Unicode-aware, so a + * CJK display name sitting flush against other CJK text is not mistaken for a standalone token. + */ + private static boolean isWordChar(char c) { + return Character.isLetterOrDigit(c) || c == '_'; + } + private static boolean isValidPlayerName(String name) { if (name == null || name.length() < 3 || name.length() > 16) { return false; @@ -495,172 +478,6 @@ private static ResourceLocation getInGameSkin(String playerName) { return null; } - private static ResourceLocation getDefaultSkin(String playerName) { - UUID uuid = null; - if (playerName != null && !playerName.trim().isEmpty()) { - uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + playerName).getBytes(StandardCharsets.UTF_8)); - } - if (uuid == null) { - return STEVE_SKIN; - } - return DefaultPlayerSkin.getDefaultSkin(uuid); - } - - private static void queueMojangLoad(String playerName) { - long now = System.currentTimeMillis(); - synchronized (CACHE) { - AvatarEntry entry = CACHE.get(cacheKey(playerName)); - if (entry != null && entry.state == State.LOADING) { - return; - } - if (activeRequests >= MAX_PARALLEL_REQUESTS || now - lastRequestAt < MIN_REQUEST_INTERVAL_MS) { - deleteTexture(CACHE.put(cacheKey(playerName), new AvatarEntry(State.MISS, null, now + 5_000L))); - return; - } - activeRequests++; - lastRequestAt = now; - deleteTexture(CACHE.put(cacheKey(playerName), new AvatarEntry(State.LOADING, null, now + REQUEST_TIMEOUT_MS * 4L))); - } - - FPSMaster.async.runnable(() -> { - try { - ResourceLocation skin = loadMojangSkin(playerName); - if (skin != null) { - put(playerName, new AvatarEntry(State.READY, skin, System.currentTimeMillis() + MOJANG_TTL_MS, true)); - } else { - put(playerName, new AvatarEntry(State.MISS, null, System.currentTimeMillis() + MISS_TTL_MS)); - } - } catch (Exception exception) { - ClientLogger.warn("Failed to load chat avatar from Mojang API"); - put(playerName, new AvatarEntry(State.MISS, null, System.currentTimeMillis() + MISS_TTL_MS)); - } finally { - synchronized (CACHE) { - activeRequests = Math.max(0, activeRequests - 1); - } - } - }); - } - - private static ResourceLocation loadMojangSkin(String playerName) throws Exception { - String encodedName = URLEncoder.encode(playerName, "UTF-8"); - JsonObject userProfile = readJson("https://api.mojang.com/users/profiles/minecraft/" + encodedName); - if (userProfile == null || !userProfile.has("id") || userProfile.get("id").isJsonNull()) { - return null; - } - String playerId = userProfile.get("id").getAsString(); - if (playerId == null || playerId.trim().isEmpty()) { - return null; - } - String skinUrl = readSkinUrl(playerId.replace("-", "")); - if (skinUrl == null || skinUrl.trim().isEmpty()) { - return null; - } - BufferedImage image = readImage(skinUrl); - if (image == null || image.getWidth() < 64 || image.getHeight() < 32) { - return null; - } - BufferedImage skinImage = normalizeSkin(image); - final ResourceLocation[] result = new ResourceLocation[1]; - AtomicBoolean accepted = new AtomicBoolean(true); - CountDownLatch latch = new CountDownLatch(1); - Minecraft.getMinecraft().addScheduledTask(() -> { - try { - ResourceLocation texture = Minecraft.getMinecraft() - .getTextureManager() - .getDynamicTextureLocation("fpsmaster_chat_avatar_" + cacheKey(playerName), new DynamicTexture(skinImage)); - if (accepted.get()) { - result[0] = texture; - } else { - deleteTexture(texture); - } - } finally { - latch.countDown(); - } - }); - if (!latch.await(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - accepted.set(false); - return null; - } - return result[0]; - } - - private static String readSkinUrl(String playerId) throws Exception { - JsonObject profile = readJson("https://sessionserver.mojang.com/session/minecraft/profile/" + playerId); - if (profile == null) { - return null; - } - JsonArray properties = profile.getAsJsonArray("properties"); - if (properties == null) { - return null; - } - for (JsonElement element : properties) { - JsonObject property = element.getAsJsonObject(); - if (!"textures".equals(property.get("name").getAsString()) || !property.has("value")) { - continue; - } - String decoded = new String(Base64.getDecoder().decode(property.get("value").getAsString()), StandardCharsets.UTF_8); - JsonObject decodedJson = GSON.fromJson(decoded, JsonObject.class); - if (decodedJson == null || !decodedJson.has("textures") || !decodedJson.get("textures").isJsonObject()) { - continue; - } - JsonObject textures = decodedJson.getAsJsonObject("textures"); - if (textures.has("SKIN") && textures.get("SKIN").isJsonObject()) { - JsonObject skin = textures.getAsJsonObject("SKIN"); - if (skin.has("url") && !skin.get("url").isJsonNull()) { - return skin.get("url").getAsString(); - } - } - } - return null; - } - - private static JsonObject readJson(String url) throws Exception { - URLConnection connection = new URL(url).openConnection(); - connection.setConnectTimeout(REQUEST_TIMEOUT_MS); - connection.setReadTimeout(REQUEST_TIMEOUT_MS); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { - return GSON.fromJson(reader, JsonObject.class); - } - } - - private static BufferedImage readImage(String url) throws Exception { - URLConnection connection = new URL(url).openConnection(); - connection.setConnectTimeout(REQUEST_TIMEOUT_MS); - connection.setReadTimeout(REQUEST_TIMEOUT_MS); - try (InputStream inputStream = connection.getInputStream()) { - return ImageIO.read(inputStream); - } - } - - private static BufferedImage normalizeSkin(BufferedImage source) { - BufferedImage converted = new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB); - Graphics2D graphics = converted.createGraphics(); - try { - graphics.drawImage(source, 0, 0, Math.min(source.getWidth(), 64), Math.min(source.getHeight(), 64), null); - } finally { - graphics.dispose(); - } - return converted; - } - - private static void deleteTexture(AvatarEntry entry) { - if (entry != null && entry.dynamicTexture) { - deleteTexture(entry.texture); - } - } - - private static void deleteTexture(ResourceLocation texture) { - if (texture == null) { - return; - } - Minecraft minecraft = Minecraft.getMinecraft(); - if (minecraft == null || minecraft.getTextureManager() == null) { - return; - } - TextureManager textureManager = minecraft.getTextureManager(); - minecraft.addScheduledTask(() -> textureManager.deleteTexture(texture)); - } - private static String cacheKey(String playerName) { return playerName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]", "_"); } diff --git a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java index 2f7bc068..e9236e6a 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java +++ b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java @@ -2,7 +2,6 @@ import top.fpsmaster.features.manager.Category; import top.fpsmaster.features.manager.Module; -import top.fpsmaster.features.settings.impl.BooleanSetting; import top.fpsmaster.features.settings.impl.NumberSetting; import net.minecraft.util.IChatComponent; import net.minecraft.util.ResourceLocation; @@ -10,7 +9,6 @@ public class ChatAvatars extends Module { public static boolean using = false; - public static final BooleanSetting mojangFallback = new BooleanSetting("MojangFallback", false); public static final NumberSetting size = new NumberSetting("Size", 8, 6, 9, 1); public static final NumberSetting gap = new NumberSetting("Gap", 4, 1, 8, 1); public static final NumberSetting offsetX = new NumberSetting("OffsetX", 0, -8, 8, 1); @@ -18,7 +16,7 @@ public class ChatAvatars extends Module { public ChatAvatars() { super("ChatAvatars", Category.RENDER); - addSettings(mojangFallback, size, gap, offsetX, offsetY); + addSettings(size, gap, offsetX, offsetY); } @Override @@ -60,6 +58,6 @@ public static ResourceLocation getAvatar(IChatComponent chatComponent) { if (!isUsing()) { return null; } - return ChatAvatarCache.getAvatar(chatComponent, mojangFallback.getValue()); + return ChatAvatarCache.getAvatar(chatComponent); } } diff --git a/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java b/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java index ffe7bc86..3d129c09 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java +++ b/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java @@ -175,7 +175,6 @@ public void onDisable() { public static void blur(float multiplier) { if (OpenGlHelper.isFramebufferEnabled()) { - GL11.glPushAttrib(GL11.GL_ENABLE_BIT | GL11.GL_COLOR_BUFFER_BIT | GL11.GL_CURRENT_BIT | GL11.GL_TEXTURE_BIT); GlStateManager.matrixMode(GL11.GL_PROJECTION); GlStateManager.pushMatrix(); GlStateManager.matrixMode(GL11.GL_MODELVIEW); @@ -235,7 +234,15 @@ public static void blur(float multiplier) { GlStateManager.popMatrix(); GlStateManager.matrixMode(GL11.GL_MODELVIEW); GlStateManager.popMatrix(); - GL11.glPopAttrib(); + + // OpenGlHelper.glBlendFunc and bindFramebufferTexture write raw GL, bypassing + // GlStateManager's cache. Push the equivalent state back *through* GlStateManager so + // cache and driver agree. Using glPopAttrib here instead would revert the driver + // behind the cache, silently turning the next enableBlend()/color() into a no-op. + GlStateManager.tryBlendFuncSeparate(770, 771, 1, 0); + GlStateManager.enableBlend(); + GlStateManager.color(1f, 1f, 1f, 1f); + GlStateManager.bindTexture(0); } } } diff --git a/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java b/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java index 9a73eeef..a0b793a7 100644 --- a/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java +++ b/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java @@ -90,11 +90,16 @@ public String trimStringToWidth(String text, float width) { return trimString(text, width, false); } + /** + * Deliberately does not run {@link GlobalTextFilter}: callers such as {@code TextField} use + * the returned length as an index back into the original, unfiltered string, and NameProtect's + * substitution changes the length. Filtering belongs to the draw path only. + */ public String trimString(String text, float width, boolean reverse) { if (text == null || text.isEmpty()) { return ""; } - return stringCache.trimStringToWidth(GlobalTextFilter.filter(text), width, reverse); + return stringCache.trimStringToWidth(text, width, reverse); } /** @@ -157,7 +162,11 @@ private int drawHighDensityString(String text, float x, float y, int color, bool GL11.glPushMatrix(); GL11.glScalef(inverseScale, inverseScale, 1.0f); try { - int result = renderer.drawStringInternal(text, x * actualDensityScale, y * actualDensityScale, color, dropShadow, actualDensityScale * 0.5f); + // Offset is expressed in the density renderer's device space, so scale the 1px GUI-space + // shadow by the same factor as the coordinates. Passing half of this (the old value) came + // back as a 0.5px GUI shadow after the inverse scale, i.e. the very offset that pixel + // snapping in StringCache.renderString was changed to avoid. + int result = renderer.drawStringInternal(text, x * actualDensityScale, y * actualDensityScale, color, dropShadow, actualDensityScale); return Math.round(result * inverseScale); } finally { GL11.glPopMatrix(); diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java index ed72eeeb..61d0753b 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java @@ -18,6 +18,8 @@ import top.fpsmaster.features.impl.render.ChatAvatars; import java.awt.*; +import java.util.ArrayDeque; +import java.util.Deque; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; @@ -58,9 +60,14 @@ public abstract class MixinGuiNewChat { @Unique private static final int FPSMASTER_MAX_CHAT_MESSAGE_SOURCES = 512; + // Identity-keyed on purpose: IChatComponent.hashCode walks the whole sibling tree and is not safe + // to call on arbitrary server-supplied components. @Unique private final Map fpsmaster$chatMessageSources = new IdentityHashMap<>(); + @Unique + private final Deque fpsmaster$chatMessageOrder = new ArrayDeque<>(); + /** * @author SuperSkidder * @reason betterchat @@ -105,7 +112,9 @@ public void drawChat(int updateCounter) { ++l; if (o > 3) { int q = -m * 9; - Gui.drawRect(-2 - chatAvatarOffset, q - 9, k + 4, q, o / 2 << 24); + // Right edge also shifts back by the gutter, otherwise the whole + // panel grows wider than the configured chat width. + Gui.drawRect(-2 - chatAvatarOffset, q - 9, k + 4 - chatAvatarOffset, q, o / 2 << 24); drawChatAvatar(chatLine, -chatAvatarOffset + 1 + ChatAvatars.getOffsetX(), q - 8 + ChatAvatars.getOffsetY(), o); String string = chatLine.getChatComponent().getFormattedText(); GlStateManager.enableBlend(); @@ -169,7 +178,7 @@ public void drawChat(int updateCounter) { if (alpha > 3) { int q = -m * 9; int alpha1 = (int) ((alpha / 255f) * module.backgroundColor.getColor().getAlpha()); - Gui.drawRect(-2 - chatAvatarOffset, q - 8, k + 4, q + 1, Colors.alpha(module.backgroundColor.getColor(), alpha1).getRGB()); + Gui.drawRect(-2 - chatAvatarOffset, q - 8, k + 4 - chatAvatarOffset, q + 1, Colors.alpha(module.backgroundColor.getColor(), alpha1).getRGB()); drawChatAvatar(chatLine, -chatAvatarOffset + 1 + ChatAvatars.getOffsetX(), q - 8 + ChatAvatars.getOffsetY() + Math.round(6 - (alpha / 255f) * 6), alpha); String string = chatLine.getChatComponent().getFormattedText(); GlStateManager.enableBlend(); @@ -213,9 +222,11 @@ public IChatComponent getChatComponent(int mouseX, int mouseY) { ScaledResolution scaledResolution = new ScaledResolution(mc); int i = scaledResolution.getScaleFactor(); float f = this.getChatScale(); - int j = mouseX / i - 2; + // drawChat translates by (2 + avatar gutter) and only then scales, so the gutter lives in + // unscaled screen space and has to come off before the divide, not after. + int j = mouseX / i - 2 - ChatAvatars.getChatOffset(); int k = mouseY / i - 40; - j = MathHelper.floor_float((float) j / f) - ChatAvatars.getChatOffset(); + j = MathHelper.floor_float((float) j / f); k = MathHelper.floor_float((float) k / f); if (j >= 0 && k >= 0) { int lineCount = Math.min(this.getLineCount(), this.drawnChatLines.size()); @@ -273,11 +284,15 @@ public List spilt(IChatComponent chatComponent, int i, FontRende @Unique private void fpsmaster$rememberChatMessageSource(IChatComponent source, List lines) { - if (fpsmaster$chatMessageSources.size() >= FPSMASTER_MAX_CHAT_MESSAGE_SOURCES) { - fpsmaster$chatMessageSources.clear(); - } for (IChatComponent line : lines) { - fpsmaster$chatMessageSources.put(line, source); + if (fpsmaster$chatMessageSources.put(line, source) == null) { + fpsmaster$chatMessageOrder.addLast(line); + } + } + // Evict oldest-first instead of wiping the map: a wholesale clear would drop the mapping for + // lines that are still on screen, blanking their avatars while the gutter stays reserved. + while (fpsmaster$chatMessageOrder.size() > FPSMASTER_MAX_CHAT_MESSAGE_SOURCES) { + fpsmaster$chatMessageSources.remove(fpsmaster$chatMessageOrder.removeFirst()); } } diff --git a/src/main/resources/assets/minecraft/client/lang/en_us.lang b/src/main/resources/assets/minecraft/client/lang/en_us.lang index 3eb9723d..12041472 100644 --- a/src/main/resources/assets/minecraft/client/lang/en_us.lang +++ b/src/main/resources/assets/minecraft/client/lang/en_us.lang @@ -215,7 +215,6 @@ betterchat.copymessage=Copy Message chatavatars=Chat Avatars chatavatars.desc=Shows player heads next to chat messages -chatavatars.mojangfallback=Mojang Fallback chatavatars.size=Avatar Size chatavatars.gap=Avatar Gap chatavatars.offsetx=Horizontal Offset diff --git a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang index e5d3461d..2ca4dc23 100644 --- a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang +++ b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang @@ -215,7 +215,6 @@ betterchat.copymessage=复制消息 chatavatars=聊天头像 chatavatars.desc=在聊天消息旁显示玩家头像 -chatavatars.mojangfallback=Mojang 备用加载 chatavatars.size=头像大小 chatavatars.gap=头像间距 chatavatars.offsetx=水平偏移