diff --git a/src/main/java/top/fpsmaster/event/EventDispatcher.java b/src/main/java/top/fpsmaster/event/EventDispatcher.java index 9b1e9782..dd8aad7a 100644 --- a/src/main/java/top/fpsmaster/event/EventDispatcher.java +++ b/src/main/java/top/fpsmaster/event/EventDispatcher.java @@ -21,6 +21,12 @@ public static void registerListener(Object listener) { if (Event.class.isAssignableFrom(parameterType)) { Class eventType = (Class) parameterType; List listeners = eventListeners.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>()); + // Identity + method: Module.onEnable / always-on ctor registration used to stack + // duplicate handlers for the same listener, retaining extra MethodHandleHandler + // instances and double-firing every event. + if (alreadyRegistered(listeners, listener, method)) { + continue; + } listeners.add(new MethodHandleHandler(listener, method)); } } @@ -29,7 +35,9 @@ public static void registerListener(Object listener) { public static void unregisterListener(Object listener) { for (List listeners : eventListeners.values()) { - listeners.removeIf(eventListener -> eventListener.getListener().getClass().equals(listener.getClass())); + // Identity, not class equality: class-based removal dropped every instance of a type + // (or left orphans when the same class had multiple live listeners). + listeners.removeIf(handler -> handler.getListener() == listener); } } @@ -50,7 +58,13 @@ public static void dispatchEvent(Event event) { } } } -} - - + private static boolean alreadyRegistered(List listeners, Object listener, Method method) { + for (Handler handler : listeners) { + if (handler.getListener() == listener && handler.getMethod().equals(method)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/top/fpsmaster/features/GlobalListener.java b/src/main/java/top/fpsmaster/features/GlobalListener.java index ca344b98..4afb6554 100644 --- a/src/main/java/top/fpsmaster/features/GlobalListener.java +++ b/src/main/java/top/fpsmaster/features/GlobalListener.java @@ -24,7 +24,9 @@ import top.fpsmaster.event.events.*; import top.fpsmaster.features.impl.interfaces.BetterChat; import top.fpsmaster.features.impl.interfaces.ClientSettings; +import top.fpsmaster.features.impl.interfaces.TargetDisplay; import top.fpsmaster.features.impl.optimizes.Performance; +import top.fpsmaster.features.impl.render.DamageIndicator; import top.fpsmaster.modules.config.ConfigProfileUtils; import top.fpsmaster.ui.PendingScreen; import top.fpsmaster.ui.notification.NotificationManager; @@ -39,6 +41,7 @@ public class GlobalListener { private long lastFlushAt; + private boolean hadWorld; public void init() { EventDispatcher.registerListener(this); @@ -72,7 +75,14 @@ public void onTick(EventTick e) { // Entity rendering stops entirely after a disconnect, so culling cannot rely on its render // hook to notice a null world and release the old WorldClient/pending entity references. Performance.ENTITY_CULLING.updateWorld(minecraft.theWorld); - if (minecraft.theWorld != null) { + boolean hasWorld = minecraft.theWorld != null; + if (hadWorld && !hasWorld) { + // Static module fields hold strong Entity refs across disconnect otherwise. + TargetDisplay.clearTarget(); + DamageIndicator.clearState(); + } + hadWorld = hasWorld; + if (hasWorld) { ReplayRecorder.instance().startIfRequested(); } ReplayRecorder.instance().onClientTick(); diff --git a/src/main/java/top/fpsmaster/features/impl/interfaces/ClientSettings.java b/src/main/java/top/fpsmaster/features/impl/interfaces/ClientSettings.java index 1a713ce8..73995779 100644 --- a/src/main/java/top/fpsmaster/features/impl/interfaces/ClientSettings.java +++ b/src/main/java/top/fpsmaster/features/impl/interfaces/ClientSettings.java @@ -94,6 +94,9 @@ public static boolean isZoomBindDown() { public ClientSettings() { super("ClientSettings", Category.Utility); addSettings(language, keyBind, followGameScale, fixedScale, blur, theme, zoomBind, clientCommand, prefix); + // Always-on: language / blur guards must fire whether or not the module "enabled" flag is + // true in a profile. onEnable/onDisable are no-ops so ConfigManager.set(true) cannot stack + // a second registration on top of this one. EventDispatcher.registerListener(this); // get system language Locale locale = Locale.getDefault(); @@ -104,6 +107,16 @@ public ClientSettings() { } } + @Override + public void onEnable() { + // Registered once in the constructor; Module.set(true) must not register again. + } + + @Override + public void onDisable() { + // Always-on listener — do not unregister when a profile writes enabled=false. + } + @Subscribe public void onValueChange(EventValueChange e) throws FileException { if (e.setting == language){ diff --git a/src/main/java/top/fpsmaster/features/impl/interfaces/TargetDisplay.java b/src/main/java/top/fpsmaster/features/impl/interfaces/TargetDisplay.java index 6afd74cf..1a9a4362 100644 --- a/src/main/java/top/fpsmaster/features/impl/interfaces/TargetDisplay.java +++ b/src/main/java/top/fpsmaster/features/impl/interfaces/TargetDisplay.java @@ -31,12 +31,29 @@ public TargetDisplay() { addSettings(targetESP, targetHUD, espColor, omit); } + /** Drops the strong EntityPlayer ref so a disconnect cannot pin the old world graph. */ + public static void clearTarget() { + target = null; + lastHit = 0L; + } + + @Override + public void onDisable() { + super.onDisable(); + clearTarget(); + } + @Subscribe public void onRender(EventRender3D e) { - if (target != null && target.getHealth() > 0 && target.isEntityAlive() && System.currentTimeMillis() - lastHit < 3000) { - if (targetESP.getMode() == 0) { - drawCircle(target, 0.55, true); - } + if (target == null) { + return; + } + if (target.getHealth() <= 0 || !target.isEntityAlive() || System.currentTimeMillis() - lastHit >= 3000) { + clearTarget(); + return; + } + if (targetESP.getMode() == 0) { + drawCircle(target, 0.55, true); } } diff --git a/src/main/java/top/fpsmaster/features/impl/optimizes/Performance.java b/src/main/java/top/fpsmaster/features/impl/optimizes/Performance.java index 075cf628..94bdfb07 100644 --- a/src/main/java/top/fpsmaster/features/impl/optimizes/Performance.java +++ b/src/main/java/top/fpsmaster/features/impl/optimizes/Performance.java @@ -6,6 +6,7 @@ import top.fpsmaster.features.settings.impl.BooleanSetting; import top.fpsmaster.features.settings.impl.ModeSetting; import top.fpsmaster.features.settings.impl.NumberSetting; +import top.fpsmaster.utils.render.ItemModelLists; import top.fpsmaster.utils.render.TextureResolution; import top.fpsmaster.utils.render.culling.EntityCulling; @@ -529,6 +530,14 @@ public Performance() { hideDoubleTallFlowers.addChangeListener(rebuildChunks); hideFences.addChangeListener(rebuildChunks); hideFenceGates.addChangeListener(rebuildChunks); + + // Display lists are session-lived until a resource reload; turning the cache off (or the + // module) must free them immediately or they sit in driver memory for the rest of the run. + cacheItemModels.addChangeListener((setting, oldValue, newValue) -> { + if (!Boolean.TRUE.equals(newValue)) { + ItemModelLists.invalidate(); + } + }); } @@ -555,6 +564,10 @@ public void onDisable() { super.onDisable(); using = false; pendingWorldRefresh = true; + // Drop in-flight occlusion probes so pending Entity refs cannot outlive the feature, and + // free any CacheItemModels display lists that would otherwise wait for a resource reload. + ENTITY_CULLING.reset(); + ItemModelLists.invalidate(); } /** diff --git a/src/main/java/top/fpsmaster/features/impl/render/DamageIndicator.java b/src/main/java/top/fpsmaster/features/impl/render/DamageIndicator.java index b729a1fa..6c15a23d 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/DamageIndicator.java +++ b/src/main/java/top/fpsmaster/features/impl/render/DamageIndicator.java @@ -18,7 +18,10 @@ import java.util.ArrayList; public class DamageIndicator extends Module { - private EntityLivingBase lastAttack; + private static final int MAX_INDICATORS = 64; + private static final DecimalFormat DAMAGE_FORMAT = new DecimalFormat("0.00"); + + private static EntityLivingBase lastAttack; public DamageIndicator() { super("DamageIndicator", Category.RENDER); @@ -27,12 +30,28 @@ public DamageIndicator() { static ArrayList indicators = new ArrayList<>(); public static void addIndicator(float x, float y, float z, float damage) { + // Cap so a disable mid-fight (listeners stop, eviction stops) cannot leave an unbounded list. + while (indicators.size() >= MAX_INDICATORS) { + indicators.remove(0); + } indicators.add(new Damage(damage, x, y, z, 0f)); } + /** Drops retained entities and pending floats — world leave / module disable. */ + public static void clearState() { + lastAttack = null; + indicators.clear(); + } + MathTimer timer = new MathTimer(); float health = 0; + @Override + public void onDisable() { + super.onDisable(); + clearState(); + } + @Subscribe public void onAttack(EventAttack e) { if (e.target instanceof EntityLivingBase) { @@ -72,8 +91,7 @@ public void onRender(EventRender3D event) { public void doRender(Damage indicator) { Minecraft mc = Minecraft.getMinecraft(); - DecimalFormat df = new DecimalFormat("0.00"); - String damage = df.format(-indicator.damage); + String damage = DAMAGE_FORMAT.format(-indicator.damage); GL11.glPushAttrib(GL11.GL_ALPHA | GL11.GL_BLEND | GL11.GL_TEXTURE_2D | GL11.GL_LIGHTING | GL11.GL_DEPTH_TEST | GL11.GL_CULL_FACE); GL11.glPushMatrix(); GL11.glEnable(3042); 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 ebf5f9e5..11f029c2 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java +++ b/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java @@ -172,6 +172,20 @@ private boolean isUsingShader() { public void onDisable() { super.onDisable(); Minecraft.getMinecraft().entityRenderer.stopUseShader(); + // Old-mode ping-pong FBOs are static and survive toggles; without an explicit delete the + // driver's framebuffer memory stays allocated until process exit. + deleteBlurBuffers(); + } + + private static void deleteBlurBuffers() { + if (blurBufferMain != null) { + blurBufferMain.deleteFramebuffer(); + blurBufferMain = null; + } + if (blurBufferInto != null) { + blurBufferInto.deleteFramebuffer(); + blurBufferInto = null; + } } public static void blur(float multiplier) { diff --git a/src/main/java/top/fpsmaster/font/TextRenderer.java b/src/main/java/top/fpsmaster/font/TextRenderer.java index dadf82d9..b9cc5151 100644 --- a/src/main/java/top/fpsmaster/font/TextRenderer.java +++ b/src/main/java/top/fpsmaster/font/TextRenderer.java @@ -88,6 +88,9 @@ public final class TextRenderer { private static final int GEOMETRY_CACHE_LIMIT = 512; + /** Soft ceiling for the scramble cache inside one obfuscation epoch. */ + private static final int OBFUSCATED_CACHE_LIMIT = 256; + /** Upper bound for the {@link #widths} memo, kept in lockstep with the geometry cache. */ private static final int WIDTH_CACHE_LIMIT = 1024; @@ -140,7 +143,13 @@ protected boolean removeEldestEntry(Map.Entry eldest) { * are worthless the moment the epoch turns, and mixing them in would push live entries out of a * bounded LRU to make room for ones that are already stale. */ - private final Map obfuscatedCache = new HashMap(); + private final Map obfuscatedCache = + new LinkedHashMap(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > OBFUSCATED_CACHE_LIMIT; + } + }; private long obfuscationEpochStarted; diff --git a/src/main/java/top/fpsmaster/minimap/Minimap.java b/src/main/java/top/fpsmaster/minimap/Minimap.java index 89000753..1ef3622d 100644 --- a/src/main/java/top/fpsmaster/minimap/Minimap.java +++ b/src/main/java/top/fpsmaster/minimap/Minimap.java @@ -781,9 +781,27 @@ private static void drawMyTexturedModalRect(final float x, final float y, final tessellator.draw(); } + /** + * Last storage size uploaded into {@link #mapTexture}. + * + *

{@code glTexImage2D} reallocates the whole level. Calling it every HUD frame (512² RGB ≈ + * 0.75MB) orphans driver pages faster than they are reclaimed — at 60 FPS that is multi-GB per + * minute and matches "十几 GB / 半小时" reports when the minimap is on. Allocate once (or when + * the buffer size changes), then update with {@code glTexSubImage2D}. + */ + private static int mapTextureStorageW = -1; + private static int mapTextureStorageH = -1; + public static void bindTextureBuffer(final ByteBuffer image, final int width, final int height, final int par0) { - GL11.glBindTexture(3553, par0); - GL11.glTexImage2D(3553, 0, 6407, width, height, 0, 6407, 5121, image); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, par0); + if (width != mapTextureStorageW || height != mapTextureStorageH) { + GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGB, width, height, 0, + GL11.GL_RGB, GL11.GL_UNSIGNED_BYTE, (ByteBuffer) null); + mapTextureStorageW = width; + mapTextureStorageH = height; + } + GL11.glTexSubImage2D(GL11.GL_TEXTURE_2D, 0, 0, 0, width, height, + GL11.GL_RGB, GL11.GL_UNSIGNED_BYTE, image); } public static boolean shouldRenderEntity(final Entity e) { @@ -821,6 +839,9 @@ public static boolean shouldRenderEntity(final Entity e) { Minimap.triedFBO = false; Minimap.loadedFBO = false; Minimap.mapTexture = new DynamicTexture(InterfaceHandler.mapTextures); + // New GL texture id — force the next upload to allocate storage again. + mapTextureStorageW = -1; + mapTextureStorageH = -1; } public class MapLoader implements Runnable { diff --git a/src/main/java/top/fpsmaster/modules/music/MusicTextures.java b/src/main/java/top/fpsmaster/modules/music/MusicTextures.java index 4ce2378a..71717edc 100644 --- a/src/main/java/top/fpsmaster/modules/music/MusicTextures.java +++ b/src/main/java/top/fpsmaster/modules/music/MusicTextures.java @@ -21,6 +21,7 @@ import java.util.Base64; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; @@ -32,6 +33,9 @@ * *

下载/解码在后台线程完成,纹理上传(GL 调用)通过 {@link Minecraft#addScheduledTask(Runnable)} * 回到渲染线程。首帧返回 {@code null},调用方每帧重新查询即可(就绪后返回同一个 location)。 + * + *

READY 是有界 LRU:封面浏览可以产生无限多个不同 URL,不淘汰会把 DynamicTexture 永久留在 + * TextureManager 里。淘汰与 {@link #invalidate(String)} 都会 {@code deleteTexture}。 */ public final class MusicTextures { @@ -39,7 +43,20 @@ public final class MusicTextures { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36"; - private static final Map READY = new HashMap<>(); + private static final int MAX_READY = 96; + private static final int MAX_LOADING = 32; + + private static final Map READY = + new LinkedHashMap(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + if (size() <= MAX_READY) { + return false; + } + deleteTexture(eldest.getValue()); + return true; + } + }; private static final Set LOADING = new HashSet<>(); // 所有 AWT/ImageIO 图片解码放到单一线程串行执行:macOS(尤其 Rosetta) 下并发调用 @@ -68,7 +85,7 @@ public static synchronized ResourceLocation cover(final String url) { final String key = "cover:" + url; ResourceLocation loc = READY.get(key); if (loc != null) return loc; - if (LOADING.contains(key)) return null; + if (LOADING.contains(key) || LOADING.size() >= MAX_LOADING) return null; LOADING.add(key); NET_EXEC.execute(new Runnable() { @Override @@ -108,7 +125,7 @@ public static synchronized ResourceLocation base64Image(final String base64OrDat final String key = "b64:" + Integer.toHexString(base64OrDataUrl.hashCode()); ResourceLocation loc = READY.get(key); if (loc != null) return loc; - if (LOADING.contains(key)) return null; + if (LOADING.contains(key) || LOADING.size() >= MAX_LOADING) return null; LOADING.add(key); IMG_EXEC.execute(new Runnable() { @Override @@ -132,11 +149,12 @@ public void run() { } /** 由文本(网易云登录 codekey URL)生成二维码纹理。 */ - public static synchronized ResourceLocation qr(final String text) { if (text == null || text.isEmpty()) return null; + public static synchronized ResourceLocation qr(final String text) { + if (text == null || text.isEmpty()) return null; final String key = "qr:" + text; ResourceLocation loc = READY.get(key); if (loc != null) return loc; - if (LOADING.contains(key)) return null; + if (LOADING.contains(key) || LOADING.size() >= MAX_LOADING) return null; LOADING.add(key); IMG_EXEC.execute(new Runnable() { @Override @@ -157,7 +175,8 @@ public void run() { public static synchronized void invalidate(String rawKey) { for (String prefix : new String[]{"cover:", "b64:", "qr:"}) { String k = prefix + rawKey; - READY.remove(k); + ResourceLocation loc = READY.remove(k); + deleteTexture(loc); LOADING.remove(k); } } @@ -193,6 +212,16 @@ private static synchronized void unmark(String key) { LOADING.remove(key); } + private static void deleteTexture(ResourceLocation location) { + if (location == null) { + return; + } + Minecraft mc = Minecraft.getMinecraft(); + if (mc != null && mc.getTextureManager() != null) { + mc.getTextureManager().deleteTexture(location); + } + } + private static void upload(final String key, final BufferedImage raw) { if (raw == null) { unmark(key); @@ -213,7 +242,11 @@ public void run() { .getDynamicTextureLocation("music_" + Integer.toHexString(key.hashCode()), new DynamicTexture(argb)); synchronized (MusicTextures.class) { - READY.put(key, loc); + ResourceLocation previous = READY.put(key, loc); + // Same key re-upload (QR refresh) must free the previous GL texture. + if (previous != null && previous != loc) { + deleteTexture(previous); + } LOADING.remove(key); } } catch (Throwable e) { diff --git a/src/main/java/top/fpsmaster/utils/core/Utility.java b/src/main/java/top/fpsmaster/utils/core/Utility.java index 2480f1be..f6a40411 100644 --- a/src/main/java/top/fpsmaster/utils/core/Utility.java +++ b/src/main/java/top/fpsmaster/utils/core/Utility.java @@ -11,6 +11,8 @@ public class Utility { public static Minecraft mc = Minecraft.getMinecraft(); + private static final int MAX_QUEUED_MESSAGES = 100; + static ArrayList messages = new ArrayList<>(); public static void sendChatMessage(String message) { @@ -22,7 +24,7 @@ public static void sendClientMessage(String msg) { if (mc.theWorld != null) { mc.ingameGUI.getChatGUI().printChatMessage(new ChatComponentText(msg)); } else { - messages.add(msg); + queueMessage(msg); } } @@ -31,8 +33,15 @@ public static void sendClientNotify(String msg) { if (mc.theWorld != null) { mc.ingameGUI.getChatGUI().printChatMessage(new ChatComponentText(msg1)); } else { - messages.add(msg1); + queueMessage(msg1); + } + } + + private static void queueMessage(String msg) { + while (messages.size() >= MAX_QUEUED_MESSAGES) { + messages.remove(0); } + messages.add(msg); } public static void sendClientDebug(String msg) { diff --git a/src/main/java/top/fpsmaster/utils/render/ItemModelLists.java b/src/main/java/top/fpsmaster/utils/render/ItemModelLists.java index a790ff05..9f43b97f 100644 --- a/src/main/java/top/fpsmaster/utils/render/ItemModelLists.java +++ b/src/main/java/top/fpsmaster/utils/render/ItemModelLists.java @@ -68,6 +68,18 @@ public final class ItemModelLists { /** Distinct colours one model may be cached under before it is dropped instead. */ private static final int MAX_TINTS_PER_MODEL = 8; + /** + * Soft ceiling on live display lists for the whole session. + * + *

Lists are otherwise only freed on a resource reload. A long session with many unique items + * (or a tinted model that approaches the per-model colour cap) would otherwise pin hundreds of + * GL lists until the player reloads resources. Flushing when the ceiling is hit trades a cold + * miss storm for bounded driver memory — the same trade the font LRU already makes. + */ + private static final int MAX_TOTAL_LISTS = 512; + + private static int totalLists; + private ItemModelLists() { } @@ -107,6 +119,9 @@ public static boolean recording() { * item is exactly the sort of fault that hides in a benchmark and shows up in a player's hand. */ public static int beginRecording(IBakedModel model, ItemStack stack) { + if (totalLists >= MAX_TOTAL_LISTS) { + invalidate(); + } Map byColour = LISTS.get(model); if (byColour == null) { byColour = new HashMap(4); @@ -122,6 +137,7 @@ public static int beginRecording(IBakedModel model, ItemStack stack) { return 0; } byColour.put(Long.valueOf(signature(model, stack)), Integer.valueOf(list)); + totalLists++; GL11.glNewList(list, GL11.GL_COMPILE_AND_EXECUTE); recording = true; return list; @@ -147,6 +163,7 @@ public static void invalidate() { LISTS.clear(); REJECTED.clear(); TINTS.clear(); + totalLists = 0; recording = false; } diff --git a/src/test/java/top/fpsmaster/event/EventDispatcherTest.java b/src/test/java/top/fpsmaster/event/EventDispatcherTest.java new file mode 100644 index 00000000..3e231606 --- /dev/null +++ b/src/test/java/top/fpsmaster/event/EventDispatcherTest.java @@ -0,0 +1,59 @@ +package top.fpsmaster.event; + +import org.junit.jupiter.api.Test; +import top.fpsmaster.event.events.EventTick; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class EventDispatcherTest { + + @Test + void registerIsIdempotentForSameListener() { + AtomicInteger hits = new AtomicInteger(); + CountingListener listener = new CountingListener(hits); + + EventDispatcher.registerListener(listener); + EventDispatcher.registerListener(listener); + EventDispatcher.dispatchEvent(new EventTick()); + assertEquals(1, hits.get(), "duplicate register must not double-fire"); + + EventDispatcher.unregisterListener(listener); + EventDispatcher.dispatchEvent(new EventTick()); + assertEquals(1, hits.get(), "unregister must remove the single handler"); + } + + @Test + void unregisterUsesIdentityNotClass() { + AtomicInteger hitsA = new AtomicInteger(); + AtomicInteger hitsB = new AtomicInteger(); + CountingListener a = new CountingListener(hitsA); + CountingListener b = new CountingListener(hitsB); + + EventDispatcher.registerListener(a); + EventDispatcher.registerListener(b); + EventDispatcher.unregisterListener(a); + EventDispatcher.dispatchEvent(new EventTick()); + + assertEquals(0, hitsA.get()); + assertEquals(1, hitsB.get(), "sibling instance of the same class must stay registered"); + + EventDispatcher.unregisterListener(b); + EventDispatcher.dispatchEvent(new EventTick()); + assertEquals(1, hitsB.get()); + } + + private static final class CountingListener { + private final AtomicInteger hits; + + private CountingListener(AtomicInteger hits) { + this.hits = hits; + } + + @Subscribe + public void onTick(EventTick event) { + hits.incrementAndGet(); + } + } +}