From a46864074fb85714ba01e30280654485feef13a3 Mon Sep 17 00:00:00 2001 From: Dominic Seung Date: Thu, 22 Oct 2026 09:24:10 +0000 Subject: [PATCH] Restore fast item glint rendering and avoid animation-toggle reloads Batch the previous GUI glint overlay, skip block glint while 1.7 Animations is enabled, and cache static sprite quads. Select already-baked trapdoor models without reloading resources. Add in-client regression checks and rendering benchmarks. Co-Authored-By: GPT-6 --- .../config/OldAnimationsConfig.java | 35 +-- .../mixin/BasicBakedModelMixin.java | 48 ++++ .../mixin/ItemRendererMixin.java | 43 +++- .../oldanimations/util/GuiGlintRenderer.java | 73 ++++++ .../util/SpriteQuadProvider.java | 51 ++++ .../lang/en_us.json | 2 +- .../main/resources/oldanimations.mixins.json | 1 + versions/1.8.9/src/test/README.md | 80 ++++++ .../axolotlclient/DolphinClientTest.java | 5 + .../github/axolotlclient/RenderBenchmark.java | 120 +++++++++ .../oldanimations/GlintRenderChecks.java | 214 ++++++++++++++++ .../OldAnimationsRegressionTest.java | 241 ++++++++++++++++++ .../test/mixin/GlintPassCounterMixin.java | 40 +++ .../resources/dolphinclient-test.mixins.json | 7 + .../1.8.9/src/test/resources/fabric.mod.json | 1 + 15 files changed, 923 insertions(+), 38 deletions(-) create mode 100644 versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/mixin/BasicBakedModelMixin.java create mode 100644 versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/util/GuiGlintRenderer.java create mode 100644 versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/util/SpriteQuadProvider.java create mode 100644 versions/1.8.9/src/test/README.md create mode 100644 versions/1.8.9/src/test/java/io/github/axolotlclient/RenderBenchmark.java create mode 100644 versions/1.8.9/src/test/java/io/github/axolotlclient/oldanimations/GlintRenderChecks.java create mode 100644 versions/1.8.9/src/test/java/io/github/axolotlclient/oldanimations/OldAnimationsRegressionTest.java create mode 100644 versions/1.8.9/src/test/java/io/github/axolotlclient/test/mixin/GlintPassCounterMixin.java create mode 100644 versions/1.8.9/src/test/resources/dolphinclient-test.mixins.json diff --git a/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/config/OldAnimationsConfig.java b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/config/OldAnimationsConfig.java index e5bb82f31..7f6f719c9 100644 --- a/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/config/OldAnimationsConfig.java +++ b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/config/OldAnimationsConfig.java @@ -26,7 +26,6 @@ import io.github.axolotlclient.oldanimations.OldAnimations; import lombok.Getter; import net.fabricmc.loader.api.FabricLoader; -import net.minecraft.client.Minecraft; import net.ornithemc.osl.lifecycle.api.client.MinecraftClientEvents; public class OldAnimationsConfig { @@ -99,8 +98,7 @@ public class OldAnimationsConfig { public final BooleanOption zombieVillagerHelmetOffset = new BooleanOption("zombieVillagerHelmetOffset", true); public final BooleanOption framedItemRotationOffset = new BooleanOption("framedItemRotationOffset", true); - /* toggling these two rebuilds cached game state, so their previous values are tracked */ - private boolean previousTrapDoorItemPosition = trapDoorItemPosition(); + /* Potion colors are cached; trapdoor models are selected at render time. */ private boolean previousOldJumpBoostPotionColor = oldJumpBoostPotionColor(); /** @@ -111,10 +109,6 @@ public static boolean isEnabled() { return instance.enabled.get(); } - private boolean trapDoorItemPosition() { - return enabled.get() && trapDoorItemPosition.get(); - } - private boolean oldJumpBoostPotionColor() { return enabled.get() && oldJumpBoostPotionColor.get(); } @@ -190,30 +184,23 @@ public void initConfig() { AxolotlClientConfig.getInstance().register(configManager); configManager.load(); - /* attempt to prevent calling resourcereload during game init */ - previousTrapDoorItemPosition = trapDoorItemPosition(); + /* Seed the effective color setting after the configuration is loaded. */ previousOldJumpBoostPotionColor = oldJumpBoostPotionColor(); - /* reload the resources upon toggling certain options */ - reloadResources(); + trackPotionColors(); } /** - * The trapdoor item model is baked at resource load, and potion colors are - * cached separately, so both need a nudge when their option is toggled. - * The master switch counts as toggling both of them. + * Both trapdoor variants are always baked by ModelBakeryMixin and selected + * by ItemModelShaperMixin. Toggling them (or the master switch) must not + * reload every resource and rebuild the world. Only potion colors need + * their cache invalidated when the effective option changes. */ - private void reloadResources() { + private void trackPotionColors() { MinecraftClientEvents.TICK_END.register(client -> { - if (trapDoorItemPosition() != previousTrapDoorItemPosition) { - previousTrapDoorItemPosition = trapDoorItemPosition(); - previousOldJumpBoostPotionColor = oldJumpBoostPotionColor(); - Minecraft.getInstance().reloadResources(); - reloadPotionColors = true; - return; - } - if (oldJumpBoostPotionColor() != previousOldJumpBoostPotionColor) { - previousOldJumpBoostPotionColor = oldJumpBoostPotionColor(); + boolean current = oldJumpBoostPotionColor(); + if (current != previousOldJumpBoostPotionColor) { + previousOldJumpBoostPotionColor = current; reloadPotionColors = true; } }); diff --git a/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/mixin/BasicBakedModelMixin.java b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/mixin/BasicBakedModelMixin.java new file mode 100644 index 000000000..1924ac7d3 --- /dev/null +++ b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/mixin/BasicBakedModelMixin.java @@ -0,0 +1,48 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.oldanimations.mixin; + +import java.util.List; + +import io.github.axolotlclient.oldanimations.util.SpriteQuadProvider; +import net.minecraft.client.resource.model.BakedQuad; +import net.minecraft.client.resource.model.BasicBakedModel; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +@Mixin(BasicBakedModel.class) +public abstract class BasicBakedModelMixin implements SpriteQuadProvider { + @Unique + private List axolotlclient$spriteSourceQuads; + + @Unique + private List axolotlclient$spriteQuads; + + @Override + public List axolotlclient$getSpriteQuads(List quads) { + // The cache belongs to the baked model, so resource reloads replace it + // along with the geometry. Glint passes reuse it without scanning or + // allocating another list on every item render. + if (axolotlclient$spriteSourceQuads != quads) { + axolotlclient$spriteQuads = SpriteQuadProvider.filter(quads); + axolotlclient$spriteSourceQuads = quads; + } + return axolotlclient$spriteQuads; + } +} diff --git a/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/mixin/ItemRendererMixin.java b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/mixin/ItemRendererMixin.java index aa08cf8e5..f3200a71e 100644 --- a/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/mixin/ItemRendererMixin.java +++ b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/mixin/ItemRendererMixin.java @@ -25,25 +25,41 @@ import com.llamalad7.mixinextras.sugar.Local; import io.github.axolotlclient.oldanimations.config.OldAnimationsConfig; import io.github.axolotlclient.oldanimations.util.ItemUtil; +import io.github.axolotlclient.oldanimations.util.GuiGlintRenderer; +import io.github.axolotlclient.oldanimations.util.SpriteQuadProvider; import net.minecraft.client.render.entity.ItemRenderer; +import net.minecraft.client.Minecraft; import net.minecraft.client.render.model.block.ModelTransformations; import net.minecraft.client.render.platform.GlStateManager; import net.minecraft.client.resource.model.BakedModel; import net.minecraft.client.resource.model.BakedQuad; import net.minecraft.item.ItemStack; -import net.minecraft.util.math.Direction; +import net.minecraft.client.render.texture.TextureManager; +import net.minecraft.resource.Identifier; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.*; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.invoke.arg.Args; -import java.util.ArrayList; import java.util.List; @Mixin(ItemRenderer.class /* C_6823239 */) public abstract class ItemRendererMixin { + @Shadow + @Final + private static Identifier ENCHANTMENT_GLINT_LOCATION; + + @Shadow + @Final + private TextureManager textureManager; + + @Shadow + public abstract boolean isGui3d(ItemStack stack); + @Unique private BakedModel axolotlclient$model = null; @@ -92,30 +108,31 @@ public abstract class ItemRendererMixin { } if (!model.isGui3d() && (axolotlclient$transform == ModelTransformations.Type.GUI || axolotlclient$shouldBeSpriteOrSwapNormal(false))) { - /* this is the easiest way to have fast items with the 1.8 model system */ - /* even forge does this xD */ - List filtered = new ArrayList<>(quads.size()); - for (BakedQuad quad : quads) { - if (quad.getFace() == Direction.SOUTH) { - filtered.add(quad); - } - } - return filtered; + return SpriteQuadProvider.get(model, quads); } return quads; } @Inject(method = "renderEnchantmentGlint", at = @At("HEAD"), cancellable = true) - private void axolotlclient$disableDefaultGlint(CallbackInfo ci) { + private void axolotlclient$disableDefaultGlint(BakedModel model, CallbackInfo ci) { if (!OldAnimationsConfig.isEnabled()) { return; } - if (axolotlclient$shouldBeSpriteOrSwapNormal(false)) { + // Keep the pre-refactor fast paths: no two extra geometry passes on + // blocks, and a flat overlay for GUI items. Master-off remains vanilla. + if (model.isGui3d() || axolotlclient$transform == ModelTransformations.Type.GUI || axolotlclient$shouldBeSpriteOrSwapNormal(false)) { ci.cancel(); } } + @Inject(method = "renderGuiItemModel", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/render/entity/ItemRenderer;renderItem(Lnet/minecraft/item/ItemStack;Lnet/minecraft/client/resource/model/BakedModel;)V", shift = At.Shift.AFTER)) + private void axolotlclient$renderGuiGlint(ItemStack stack, int x, int y, CallbackInfo ci) { + if (OldAnimationsConfig.isEnabled() && stack.hasEnchantmentGlint() && !isGui3d(stack)) { + GuiGlintRenderer.render(textureManager, ENCHANTMENT_GLINT_LOCATION, Minecraft.getTime()); + } + } + @Inject(method = "renderItemInHand(Lnet/minecraft/item/ItemStack;Lnet/minecraft/client/render/model/block/ModelTransformations$Type;)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/render/entity/ItemRenderer;renderItemInHand(Lnet/minecraft/item/ItemStack;Lnet/minecraft/client/resource/model/BakedModel;Lnet/minecraft/client/render/model/block/ModelTransformations$Type;)V")) private void axolotlclient$fastItemOffset(ItemStack stack, ModelTransformations.Type transformationType, CallbackInfo ci) { //todo: is this really needed? im not so sure anymore diff --git a/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/util/GuiGlintRenderer.java b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/util/GuiGlintRenderer.java new file mode 100644 index 000000000..34d134cc7 --- /dev/null +++ b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/util/GuiGlintRenderer.java @@ -0,0 +1,73 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.oldanimations.util; + +import net.minecraft.client.render.platform.GlStateManager; +import net.minecraft.client.render.texture.TextureAtlas; +import net.minecraft.client.render.texture.TextureManager; +import net.minecraft.client.render.vertex.BufferBuilder; +import net.minecraft.client.render.vertex.DefaultVertexFormat; +import net.minecraft.client.render.vertex.Tesselator; +import net.minecraft.resource.Identifier; + +/** The previous 1.7 GUI overlay, with both quads submitted in one draw. */ +public final class GuiGlintRenderer { + private GuiGlintRenderer() { + } + + public static void render(TextureManager textures, Identifier glint, long time) { + GlStateManager.depthMask(false); + GlStateManager.depthFunc(518); + GlStateManager.disableLighting(); + GlStateManager.blendFuncSeparate(772, 1, 0, 0); + textures.bind(glint); + GlStateManager.pushMatrix(); + try { + GlStateManager.scalef(0.5F, 0.5F, 0.5F); + GlStateManager.translatef(0.0F, -0.25F, 0.0F); + Tesselator tesselator = Tesselator.getInstance(); + BufferBuilder buffer = tesselator.getBuffer(); + buffer.begin(7, DefaultVertexFormat.POSITION_TEX_COLOR); + appendQuad(buffer, (float) (time % 3000L) / 3000.0F, 4.0F); + appendQuad(buffer, (float) (time % 4873L) / 4873.0F, -1.0F); + tesselator.end(); + } finally { + GlStateManager.popMatrix(); + GlStateManager.blendFuncSeparate(770, 771, 1, 0); + GlStateManager.enableLighting(); + GlStateManager.depthFunc(515); + GlStateManager.depthMask(true); + textures.bind(TextureAtlas.BLOCKS_LOCATION); + } + } + + private static void appendQuad(BufferBuilder buffer, float speed, float skew) { + // Same positions, UVs and color as the removed GlintQuadModel, without + // allocating a model, vertex arrays or lists for each overlay pass. + float dimension = 20.0F / 256.0F; + vertex(buffer, -0.5F, -0.25F, speed + dimension * skew, dimension); + vertex(buffer, 0.5F, -0.25F, speed + dimension + dimension * skew, dimension); + vertex(buffer, 0.5F, 0.75F, speed + dimension, 0.0F); + vertex(buffer, -0.5F, 0.75F, speed, 0.0F); + } + + private static void vertex(BufferBuilder buffer, float x, float y, float u, float v) { + buffer.vertex(x, y, 0.0F).texture(u, v).color(128, 64, 204, 255).nextVertex(); + } +} diff --git a/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/util/SpriteQuadProvider.java b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/util/SpriteQuadProvider.java new file mode 100644 index 000000000..bb481116c --- /dev/null +++ b/versions/1.8.9/src/main/java/io/github/axolotlclient/oldanimations/util/SpriteQuadProvider.java @@ -0,0 +1,51 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.oldanimations.util; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.client.resource.model.BakedModel; +import net.minecraft.client.resource.model.BakedQuad; +import net.minecraft.client.resource.model.BasicBakedModel; +import net.minecraft.util.math.Direction; + +/** The south-facing quads used by the flat item renderer. */ +public interface SpriteQuadProvider { + List axolotlclient$getSpriteQuads(List quads); + + static List get(BakedModel model, List quads) { + // Vanilla baked models keep the same quad list for their lifetime. Do not + // cache custom models or subclasses: they may return dynamic geometry. + if (model.getClass() == BasicBakedModel.class) { + return ((SpriteQuadProvider) model).axolotlclient$getSpriteQuads(quads); + } + return filter(quads); + } + + static List filter(List quads) { + List filtered = new ArrayList<>(); + for (BakedQuad quad : quads) { + if (quad.getFace() == Direction.SOUTH) { + filtered.add(quad); + } + } + return List.copyOf(filtered); + } +} diff --git a/versions/1.8.9/src/main/resources/assets/axolotlclient-oldanimations/lang/en_us.json b/versions/1.8.9/src/main/resources/assets/axolotlclient-oldanimations/lang/en_us.json index d0c7b0eb8..fcab84585 100644 --- a/versions/1.8.9/src/main/resources/assets/axolotlclient-oldanimations/lang/en_us.json +++ b/versions/1.8.9/src/main/resources/assets/axolotlclient-oldanimations/lang/en_us.json @@ -91,7 +91,7 @@ "thirdPersonPositions": "Third Person Item Positions", "thirdPersonPositions.tooltip": "Reverts third person item transformations to match 1.7. Currently, this feature will disable the ability for resource packs to alter item positions, however, this is subject to change in the future.", "trapDoorItemPosition": "Trapdoor Item Position", - "trapDoorItemPosition.tooltip": "Reverts the trapdoor item model position when viewed in the inventory, while held, or dropped to match 1.7. Toggling this will reload the game resources!", + "trapDoorItemPosition.tooltip": "Reverts the trapdoor item model position when viewed in the inventory, while held, or dropped to match 1.7.", "useAndMineSound": "Use and Mine Sound", "useAndMineSound.tooltip": "Plays block mining sounds while using and item and mining to imiate block breaking.", "framedItemRotationOffset": "Framed Item Rotation Offset", diff --git a/versions/1.8.9/src/main/resources/oldanimations.mixins.json b/versions/1.8.9/src/main/resources/oldanimations.mixins.json index a713df5b2..956ec853d 100644 --- a/versions/1.8.9/src/main/resources/oldanimations.mixins.json +++ b/versions/1.8.9/src/main/resources/oldanimations.mixins.json @@ -6,6 +6,7 @@ "minVersion": "0.5.0" }, "client": [ + "BasicBakedModelMixin", "ClientPlayNetworkHandlerMixin", "ClientPlayerInteractionManagerAccessor", "ClientPlayerInteractionManagerMixin", diff --git a/versions/1.8.9/src/test/README.md b/versions/1.8.9/src/test/README.md new file mode 100644 index 000000000..1e9c9ee38 --- /dev/null +++ b/versions/1.8.9/src/test/README.md @@ -0,0 +1,80 @@ +# Rendering regression checks + +These checks run inside the development client so they exercise real Minecraft +models and applied mixins. They are opt-in and are not packaged in the release JAR. +They exit the client automatically; use a development run directory, not a live +Minecraft session. + +```sh +./gradlew :1.8.9:build +DOLPHIN_RENDER_TESTS=1 ./gradlew :1.8.9:runClient +DOLPHIN_BENCHMARK=1 ./gradlew :1.8.9:runClient +``` + +A working OpenGL display is required. On a headless Linux machine, set `DISPLAY` +to an Xorg/Xvfb display. `LP_NUM_THREADS=1` makes Mesa software-renderer comparisons +less sensitive to its thread scheduling. Run comparisons sequentially, with the +same JVM, renderer, resource packs and configuration. + +## Regression coverage + +`OLD_ANIMATIONS_REGRESSION_TESTS PASSED` confirms: + +- South-face filtering preserves quad identity, order and tint layers without + mutating source geometry; empty results are cached too. +- Repeated renders reuse the filtered list. Replacing the source list invalidates + it. Dynamic model subclasses bypass caching, including in-place list changes. +- Wooden and iron trapdoor models switch correctly under both the individual and + master toggles, without rebaking resources. +- Effective potion-color changes invalidate the cache exactly when needed, and + the real potion lookup restores the expected old/vanilla leaping color. +- An explicit resource reload replaces both baked models and their sprite caches. +- The enabled mod skips vanilla glint geometry passes for GUI items and blocks; + disabling it restores both vanilla passes in GUI and held-item rendering. +- The batched GUI overlay matches the previous renderer pixel-for-pixel at five + animation phases, produces visible output, and restores depth state. + +The filter microbenchmark compares the exact previous implementation against the +new cache using synthetic 66-, 258- and 1,026-quad models. After 20,000 warmup calls, +it measures 100,000 calls per implementation, retains results in a volatile field, +and asserts that cache-hit allocation is less than 1% of the old allocation. +Its timings are not a world-FPS measurement. + +## Render comparison + +`RenderBenchmark` renders batches of nine items through the actual GUI and held +item paths. Each case has 1,000 warmup batches and 1,000 measured batches. It waits +for OpenGL completion and reports median/p95/p99 batch time, render-thread +allocation and GL errors. Cases include plain/enchanted swords, plain/enchanted +blocks, and skulls. The GUI cases exercise the same item helper as the hotbar. + +For comparisons against an older revision, copy `RenderBenchmark.java` to that +revision's test source set and add this opt-in line after bridge validation in +`DolphinClientTest.onInitializeClient()`: + +```java +if (System.getenv("DOLPHIN_BENCHMARK") != null) RenderBenchmark.install(); +``` + +This benchmark does not simulate a populated multiplayer world, the Dolphin +Optimized modpack, or the user's GPU. Use an identical world/camera, resource pack +and modpack on real hardware to verify the reported gameplay FPS regression. + +## Measurements for the PR #28 follow-up + +Tested with JDK 25, Mesa llvmpipe, one rendering worker, default resources, and +1.7 Animations enabled. PR #28 was `dd2ce3fb0`; its base was `591f42dd5`. +The production fix retains the pre-refactor GUI overlay and absence of block +item glint while the mod is enabled; it does not restore the removed settings. + +| Nine-item GUI batch | PR #28 allocation | Fixed allocation | +| --- | ---: | ---: | +| Plain swords | 2,880 B | 720 B | +| Enchanted swords | 7,848 B | 792 B | + +The enchanted-block median fell from 395.21 µs to 115.70 µs per nine-item batch +when the two extra glint passes were removed (about 71%). Other timing results +were noisy, so this is not a claim of a general FPS multiplier. The sprite-filter +microbenchmark measured 304 / 1,072 / 4,144 B per call before the fix and 0 B on +warm cache hits for the three synthetic model sizes. Toggling settings preserved +baked-model identities; an explicit resource reload replaced them as expected. diff --git a/versions/1.8.9/src/test/java/io/github/axolotlclient/DolphinClientTest.java b/versions/1.8.9/src/test/java/io/github/axolotlclient/DolphinClientTest.java index b9382c151..708d497b9 100644 --- a/versions/1.8.9/src/test/java/io/github/axolotlclient/DolphinClientTest.java +++ b/versions/1.8.9/src/test/java/io/github/axolotlclient/DolphinClientTest.java @@ -31,5 +31,10 @@ public class DolphinClientTest implements ClientModInitializer { @Override public void onInitializeClient() { BridgeValidationPostprocessor.validate(); + if (System.getenv("DOLPHIN_RENDER_TESTS") != null) { + io.github.axolotlclient.oldanimations.OldAnimationsRegressionTest.install(); + } else if (System.getenv("DOLPHIN_BENCHMARK") != null) { + RenderBenchmark.install(); + } } } diff --git a/versions/1.8.9/src/test/java/io/github/axolotlclient/RenderBenchmark.java b/versions/1.8.9/src/test/java/io/github/axolotlclient/RenderBenchmark.java new file mode 100644 index 000000000..0c9dd1994 --- /dev/null +++ b/versions/1.8.9/src/test/java/io/github/axolotlclient/RenderBenchmark.java @@ -0,0 +1,120 @@ +/* + * Copyright © 2026 DSNS + * + * This file is part of DolphinClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient; + +import java.lang.management.ManagementFactory; +import java.util.Arrays; +import java.util.Locale; + +import net.minecraft.block.Blocks; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.render.model.block.ModelTransformations; +import net.minecraft.client.render.platform.GlStateManager; +import net.minecraft.client.render.platform.Lighting; +import net.minecraft.enchantment.Enchantment; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.ornithemc.osl.lifecycle.api.client.MinecraftClientEvents; +import org.lwjgl.opengl.GL11; + +/** + * Opt-in rendering microbenchmark. Measures nine-item batches, not world FPS. + * The same source can be copied to the parent PR/base for an A/B comparison. + */ +public final class RenderBenchmark extends Screen { + private static final int WARMUP = 1000; + private static final int SAMPLES = 1000; + private int frame; + + public static void install() { + int[] ticks = {0}; + MinecraftClientEvents.TICK_END.register(client -> { + if (++ticks[0] == 40) client.openScreen(new RenderBenchmark()); + }); + } + + @Override + public void render(int x, int y, float delta) { + if (++frame != 5) return; + try { + System.out.println("BENCH renderer=" + GL11.glGetString(GL11.GL_RENDERER)); + ItemStack sword = new ItemStack(Items.DIAMOND_SWORD); + ItemStack enchanted = sword.copy(); + enchanted.addEnchantment(Enchantment.byId(16), 1); + ItemStack block = new ItemStack(Blocks.STONE); + ItemStack glintBlock = block.copy(); + glintBlock.addEnchantment(Enchantment.byId(16), 1); + run("gui-sword", sword, false); + run("gui-enchanted-sword", enchanted, false); + run("gui-block", block, false); + run("gui-enchanted-block", glintBlock, false); + run("gui-skull", new ItemStack(Items.SKULL, 1, 3), false); + run("held-sword", sword, true); + run("held-enchanted-sword", enchanted, true); + System.out.println("BENCH DONE"); + System.exit(0); + } catch (Throwable failure) { + failure.printStackTrace(); + System.exit(1); + } + } + + private void run(String name, ItemStack stack, boolean held) { + long[] times = new long[SAMPLES]; + var bean = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + long thread = Thread.currentThread().getId(); + long allocated = 0; + for (int sample = -WARMUP; sample < SAMPLES; sample++) { + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); + GL11.glFinish(); + long bytes = bean.getThreadAllocatedBytes(thread); + long start = System.nanoTime(); + for (int i = 0; i < 9; i++) { + if (held) { + Lighting.turnOnGui(); + GlStateManager.pushMatrix(); + GlStateManager.translatef(20 + i * 20, 20, 0); + GlStateManager.scalef(16, 16, 16); + Minecraft.getInstance().getItemRenderer().renderItemInHand(stack, ModelTransformations.Type.FIRST_PERSON); + GlStateManager.popMatrix(); + Lighting.turnOff(); + } else { + io.github.axolotlclient.modules.hud.util.ItemUtil.renderGuiItemModel(stack, 20 + i * 20, 20); + } + } + GL11.glFinish(); + long elapsed = System.nanoTime() - start; + if (sample >= 0) { + times[sample] = elapsed; + allocated += bean.getThreadAllocatedBytes(thread) - bytes; + } + } + Arrays.sort(times); + int error = GL11.glGetError(); + if (error != GL11.GL_NO_ERROR) throw new AssertionError("OpenGL error " + error + " in " + name); + System.out.printf(Locale.ROOT, "BENCH %s median_us=%.2f p95_us=%.2f p99_us=%.2f bytes_per_batch=%d gl_error=%d%n", + name, times[SAMPLES / 2] / 1000., times[SAMPLES * 95 / 100] / 1000., + times[SAMPLES * 99 / 100] / 1000., allocated / SAMPLES, error); + } +} diff --git a/versions/1.8.9/src/test/java/io/github/axolotlclient/oldanimations/GlintRenderChecks.java b/versions/1.8.9/src/test/java/io/github/axolotlclient/oldanimations/GlintRenderChecks.java new file mode 100644 index 000000000..ec728aacd --- /dev/null +++ b/versions/1.8.9/src/test/java/io/github/axolotlclient/oldanimations/GlintRenderChecks.java @@ -0,0 +1,214 @@ +/* + * Copyright © 2026 DSNS + * + * This file is part of DolphinClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.oldanimations; + +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import io.github.axolotlclient.oldanimations.config.OldAnimationsConfig; +import io.github.axolotlclient.oldanimations.util.GuiGlintRenderer; +import net.minecraft.block.Blocks; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.render.entity.ItemRenderer; +import net.minecraft.client.render.model.block.ModelTransformations; +import net.minecraft.client.render.platform.GlStateManager; +import net.minecraft.client.render.vertex.BufferBuilder; +import net.minecraft.client.render.vertex.DefaultVertexFormat; +import net.minecraft.client.render.vertex.Tesselator; +import net.minecraft.client.resource.model.BakedModel; +import net.minecraft.client.resource.model.BakedQuad; +import net.minecraft.client.resource.model.BasicBakedModel; +import net.minecraft.enchantment.Enchantment; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.resource.Identifier; +import net.minecraft.util.math.Direction; +import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.GL11; + +/** Checks the actual glint dispatch and compares pixels to the pre-#28 renderer. */ +public final class GlintRenderChecks extends Screen { + public static int vanillaPasses; + private static final Identifier GLINT = new Identifier("textures/misc/enchanted_item_glint.png"); + private int frames; + + @Override + public void render(int x, int y, float delta) { + if (++frames != 5) return; + boolean enabled = OldAnimationsConfig.isEnabled(); + try { + ItemStack sword = new ItemStack(Items.DIAMOND_SWORD); + sword.addEnchantment(Enchantment.byId(16), 1); + ItemStack block = new ItemStack(Blocks.STONE); + block.addEnchantment(Enchantment.byId(16), 1); + checkPasses(sword, true, false, 0); + checkPasses(block, true, false, 0); + checkPasses(sword, false, false, 2); + checkPasses(block, false, false, 2); + checkPasses(sword, true, true, 2); + checkPasses(block, true, true, 0); + checkPasses(sword, false, true, 2); + checkPasses(block, false, true, 2); + OldAnimationsConfig.instance.enabled.set(true); + comparePixels(); + System.out.println("OLD_ANIMATIONS_REGRESSION_TESTS PASSED (including glint dispatch and pixel equivalence)"); + OldAnimationsConfig.instance.enabled.set(enabled); + System.exit(0); + } catch (Throwable failure) { + failure.printStackTrace(); + OldAnimationsConfig.instance.enabled.set(enabled); + System.exit(1); + } + } + + private static void checkPasses(ItemStack stack, boolean enabled, boolean held, int expected) { + OldAnimationsConfig.instance.enabled.set(enabled); + vanillaPasses = 0; + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); + GlStateManager.enableDepthTest(); + GlStateManager.pushMatrix(); + try { + if (held) { + Minecraft.getInstance().getItemRenderer().renderItemInHand(stack, ModelTransformations.Type.FIRST_PERSON); + } else { + io.github.axolotlclient.modules.hud.util.ItemUtil.renderGuiItemModel(stack, 20, 20); + } + } finally { + GlStateManager.popMatrix(); + } + if (vanillaPasses != expected) throw new AssertionError("Glint passes: expected " + expected + ", got " + vanillaPasses); + checkGl(); + } + + private static void comparePixels() throws Exception { + Method render = ItemRenderer.class.getDeclaredMethod("render", BakedModel.class, int.class); + render.setAccessible(true); + int changed = 0; + for (long time : new long[]{0, 1500, 2999, 4872, 12345}) { + prepareImage(); + byte[] background = pixels(); + renderReference(render, time); + byte[] reference = pixels(); + prepareImage(); + GuiGlintRenderer.render(Minecraft.getInstance().getTextureManager(), GLINT, time); + byte[] actual = pixels(); + if (!Arrays.equals(reference, actual)) throw new AssertionError("GUI glint pixels differ at time " + time); + for (int i = 0; i < actual.length; i++) if (actual[i] != background[i]) changed++; + if (GL11.glGetInteger(GL11.GL_DEPTH_FUNC) != GL11.GL_LEQUAL || !GL11.glGetBoolean(GL11.GL_DEPTH_WRITEMASK)) { + throw new AssertionError("GUI glint leaked depth state"); + } + checkGl(); + } + if (changed == 0) throw new AssertionError("Glint comparison rendered no visible pixels"); + System.out.println("GUI_GLINT_PIXELS: exact match against pre-#28 at five animation phases"); + } + + private static void prepareImage() { + GL11.glViewport(0, 0, 64, 64); + GlStateManager.matrixMode(GL11.GL_PROJECTION); + GlStateManager.loadIdentity(); + GL11.glOrtho(-1, 1, -1, 1, -1, 1); + GlStateManager.matrixMode(GL11.GL_MODELVIEW); + GlStateManager.loadIdentity(); + GlStateManager.matrixMode(GL11.GL_TEXTURE); + GlStateManager.loadIdentity(); + GlStateManager.matrixMode(GL11.GL_MODELVIEW); + GlStateManager.disableLighting(); + GlStateManager.disableAlphaTest(); + GlStateManager.disableCull(); + GlStateManager.enableDepthTest(); + GlStateManager.depthMask(true); + GlStateManager.depthFunc(GL11.GL_LEQUAL); + GlStateManager.disableBlend(); + GlStateManager.disableTexture(); + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); + BufferBuilder buffer = Tesselator.getInstance().getBuffer(); + buffer.begin(GL11.GL_QUADS, DefaultVertexFormat.POSITION_COLOR); + buffer.vertex(-1, -1, 0).color(40, 60, 80, 255).nextVertex(); + buffer.vertex(1, -1, 0).color(40, 60, 80, 255).nextVertex(); + buffer.vertex(1, 1, 0).color(40, 60, 80, 255).nextVertex(); + buffer.vertex(-1, 1, 0).color(40, 60, 80, 255).nextVertex(); + Tesselator.getInstance().end(); + GlStateManager.enableTexture(); + GlStateManager.enableBlend(); + GlStateManager.color4f(1, 1, 1, 1); + } + + /** Original GUI glint submission from main at 591f42dd5, including two draws. */ + private static void renderReference(Method render, long time) throws Exception { + GlStateManager.depthMask(false); + GlStateManager.depthFunc(518); + GlStateManager.disableLighting(); + GlStateManager.blendFuncSeparate(772, 1, 0, 0); + Minecraft.getInstance().getTextureManager().bind(GLINT); + GlStateManager.pushMatrix(); + GlStateManager.scalef(0.5F, 0.5F, 0.5F); + GlStateManager.translatef(0.0F, -0.25F, 0.0F); + render.invoke(Minecraft.getInstance().getItemRenderer(), referenceModel((float) (time % 3000L) / 3000.0F, 4.0F), -8372020); + render.invoke(Minecraft.getInstance().getItemRenderer(), referenceModel((float) (time % 4873L) / 4873.0F, -1.0F), -8372020); + GlStateManager.popMatrix(); + GlStateManager.blendFuncSeparate(770, 771, 1, 0); + GlStateManager.enableLighting(); + GlStateManager.depthFunc(515); + GlStateManager.depthMask(true); + } + + /** Vertex data from the deleted GlintQuadModel, independent of the new builder. */ + private static BakedModel referenceModel(float speed, float skew) { + float dimension = 20.0F / 256.0F; + float[] verts = { + -0.5F, -0.25F, 0.0F, speed + dimension * skew, dimension, + 0.5F, -0.25F, 0.0F, speed + dimension + dimension * skew, dimension, + 0.5F, 0.75F, 0.0F, speed + dimension, 0.0F, + -0.5F, 0.75F, 0.0F, speed, 0.0F + }; + int[] data = new int[28]; + for (int i = 0; i < 4; i++) { + int src = i * 5; + int dst = i * 7; + data[dst] = Float.floatToRawIntBits(verts[src]); + data[dst + 1] = Float.floatToRawIntBits(verts[src + 1]); + data[dst + 2] = Float.floatToRawIntBits(verts[src + 2]); + data[dst + 4] = Float.floatToRawIntBits(verts[src + 3]); + data[dst + 5] = Float.floatToRawIntBits(verts[src + 4]); + } + return new BasicBakedModel(List.of(new BakedQuad(data, -1, Direction.SOUTH)), + List.of(List.of(), List.of(), List.of(), List.of(), List.of(), List.of()), false, false, null, ModelTransformations.NONE); + } + + private static byte[] pixels() { + ByteBuffer buffer = BufferUtils.createByteBuffer(64 * 64 * 4); + GL11.glReadPixels(0, 0, 64, 64, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buffer); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + return bytes; + } + + private static void checkGl() { + int error = GL11.glGetError(); + if (error != GL11.GL_NO_ERROR) throw new AssertionError("OpenGL error " + error); + } +} diff --git a/versions/1.8.9/src/test/java/io/github/axolotlclient/oldanimations/OldAnimationsRegressionTest.java b/versions/1.8.9/src/test/java/io/github/axolotlclient/oldanimations/OldAnimationsRegressionTest.java new file mode 100644 index 000000000..76721e036 --- /dev/null +++ b/versions/1.8.9/src/test/java/io/github/axolotlclient/oldanimations/OldAnimationsRegressionTest.java @@ -0,0 +1,241 @@ +/* + * Copyright © 2026 DSNS + * + * This file is part of DolphinClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.oldanimations; + +import java.lang.management.ManagementFactory; +import java.util.ArrayList; +import java.util.List; + +import io.github.axolotlclient.oldanimations.config.OldAnimationsConfig; +import io.github.axolotlclient.oldanimations.util.SpriteQuadProvider; +import net.minecraft.block.Blocks; +import net.minecraft.client.Minecraft; +import net.minecraft.client.render.model.block.ModelTransformations; +import net.minecraft.client.resource.model.BakedModel; +import net.minecraft.client.resource.model.BakedQuad; +import net.minecraft.client.resource.model.BasicBakedModel; +import net.minecraft.entity.living.effect.PotionHelper; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.util.math.Direction; +import net.ornithemc.osl.lifecycle.api.client.MinecraftClientEvents; + +/** Opt-in checks against Minecraft's actual, mixin-transformed model objects. */ +public final class OldAnimationsRegressionTest { + private static volatile List result; + private static boolean finished; + private static final OldAnimationsConfig CONFIG = OldAnimationsConfig.instance; + private static final ItemStack WOOD = new ItemStack(Blocks.TRAPDOOR); + private static final ItemStack IRON = new ItemStack(Blocks.IRON_TRAPDOOR); + private static final ItemStack SWORD = new ItemStack(Items.DIAMOND_SWORD); + private static BakedModel sword, oldWood, oldIron, vanillaWood, vanillaIron; + private static List oldSprite; + private static int oldPotionColor, vanillaPotionColor; + private static boolean savedEnabled, savedTrapdoor, savedPotion; + + private OldAnimationsRegressionTest() { + } + + public static void install() { + int[] ticks = {0}; + MinecraftClientEvents.TICK_END.register(client -> { + if (finished) return; + int tick = ++ticks[0]; + if (tick < 40 || tick % 4 != 0) return; + try { + step((tick - 40) / 4, client); + } catch (Throwable failure) { + failure.printStackTrace(); + restoreOptions(); + System.exit(1); + } + }); + } + + private static void step(int stage, Minecraft client) { + switch (stage) { + case 0 -> { + savedEnabled = CONFIG.enabled.get(); + savedTrapdoor = CONFIG.trapDoorItemPosition.get(); + savedPotion = CONFIG.oldJumpBoostPotionColor.get(); + CONFIG.enabled.set(true); + CONFIG.trapDoorItemPosition.set(true); + CONFIG.oldJumpBoostPotionColor.set(true); + } + case 1 -> { + testQuads(); + benchmarkFilter(); + sword = model(SWORD); + oldWood = model(WOOD); + oldIron = model(IRON); + oldSprite = SpriteQuadProvider.get(sword, sword.getQuads()); + oldPotionColor = PotionHelper.getColor(8203, false); + CONFIG.reloadPotionColors = false; + CONFIG.trapDoorItemPosition.set(false); + } + case 2 -> { + checkNoReload(); + vanillaWood = model(WOOD); + vanillaIron = model(IRON); + check(vanillaWood != oldWood && vanillaIron != oldIron, "trapdoor toggle must change both models"); + check(!CONFIG.reloadPotionColors, "trapdoor toggle must not invalidate potion colors"); + CONFIG.trapDoorItemPosition.set(true); + } + case 3 -> { + checkNoReload(); + check(model(WOOD) == oldWood && model(IRON) == oldIron, "trapdoor variants must already be baked"); + CONFIG.enabled.set(false); + } + case 4 -> { + checkNoReload(); + check(model(WOOD) == vanillaWood && model(IRON) == vanillaIron, "master off must select vanilla trapdoors"); + check(CONFIG.reloadPotionColors, "master off must invalidate potion colors"); + vanillaPotionColor = PotionHelper.getColor(8203, false); + check(oldPotionColor != vanillaPotionColor, "master toggle must change leaping potion color"); + check(!CONFIG.reloadPotionColors, "potion lookup must consume invalidation"); + } + case 5 -> { + checkNoReload(); + check(!CONFIG.reloadPotionColors, "unchanged settings must not invalidate repeatedly"); + CONFIG.enabled.set(true); + } + case 6 -> { + checkNoReload(); + check(model(WOOD) == oldWood && model(IRON) == oldIron, "master on must restore existing models"); + check(CONFIG.reloadPotionColors, "master on must invalidate potion colors"); + check(PotionHelper.getColor(8203, false) == oldPotionColor, "old potion color must be restored"); + CONFIG.oldJumpBoostPotionColor.set(false); + } + case 7 -> { + checkNoReload(); + check(CONFIG.reloadPotionColors, "individual potion option must invalidate"); + check(PotionHelper.getColor(8203, false) == vanillaPotionColor, "individual option must restore vanilla color"); + CONFIG.enabled.set(false); + } + case 8 -> { + checkNoReload(); + check(!CONFIG.reloadPotionColors, "ineffective master toggle must not invalidate colors"); + CONFIG.enabled.set(true); + } + case 9 -> { + checkNoReload(); + check(!CONFIG.reloadPotionColors, "ineffective master toggle must not invalidate colors"); + client.reloadResources(); + BakedModel reloaded = model(SWORD); + check(reloaded != sword, "explicit reload must replace the baked model"); + List fresh = SpriteQuadProvider.get(reloaded, reloaded.getQuads()); + check(fresh != oldSprite, "explicit reload must replace the sprite cache"); + check(fresh.equals(referenceFilter(reloaded.getQuads())), "reloaded cache must match new geometry"); + restoreOptions(); + finished = true; + client.openScreen(new GlintRenderChecks()); + } + default -> throw new AssertionError("Unexpected test stage " + stage); + } + } + + private static void checkNoReload() { + check(model(SWORD) == sword, "setting change unexpectedly rebaked resources"); + } + + private static void restoreOptions() { + CONFIG.enabled.set(savedEnabled); + CONFIG.trapDoorItemPosition.set(savedTrapdoor); + CONFIG.oldJumpBoostPotionColor.set(savedPotion); + } + + private static BakedModel model(ItemStack stack) { + return Minecraft.getInstance().getItemRenderer().getModelShaper().getModel(stack); + } + + private static BasicBakedModel model(List quads) { + return new BasicBakedModel(quads, List.of(List.of(), List.of(), List.of(), List.of(), List.of(), List.of()), + false, false, null, ModelTransformations.NONE); + } + + private static void testQuads() { + BakedQuad front = new BakedQuad(new int[28], 0, Direction.SOUTH); + BakedQuad back = new BakedQuad(new int[28], 0, Direction.NORTH); + BakedQuad layer = new BakedQuad(new int[28], 1, Direction.SOUTH); + List quads = List.of(back, front, layer, back); + BasicBakedModel model = model(quads); + List filtered = SpriteQuadProvider.get(model, quads); + check(filtered.equals(List.of(front, layer)), "preserve south faces, layer order, and quad identity"); + check(quads.equals(List.of(back, front, layer, back)), "do not mutate the source model"); + check(SpriteQuadProvider.get(model, quads) == filtered, "repeated renders must reuse the list"); + List replacement = List.of(layer); + check(SpriteQuadProvider.get(model, replacement).equals(replacement), "replacement quad list must invalidate cache"); + BasicBakedModel empty = model(List.of(back)); + check(SpriteQuadProvider.get(empty, empty.getQuads()).isEmpty(), "empty sprite must stay empty"); + check(SpriteQuadProvider.get(empty, empty.getQuads()) == SpriteQuadProvider.get(empty, empty.getQuads()), + "empty sprites must also be cached"); + + // A modded subclass can mutate a list in place. It must bypass the cache. + List dynamicQuads = new ArrayList<>(List.of(front)); + BasicBakedModel dynamic = new BasicBakedModel(dynamicQuads, List.of(), false, false, null, ModelTransformations.NONE) {}; + check(SpriteQuadProvider.get(dynamic, dynamic.getQuads()).equals(List.of(front)), "dynamic model initial geometry"); + dynamicQuads.set(0, layer); + check(SpriteQuadProvider.get(dynamic, dynamic.getQuads()).equals(List.of(layer)), "dynamic model updated geometry"); + } + + /** Exact pre-fix filter, also used as the geometry oracle. */ + private static List referenceFilter(List quads) { + List filtered = new ArrayList<>(quads.size()); + for (BakedQuad quad : quads) { + if (quad.getFace() == Direction.SOUTH) filtered.add(quad); + } + return filtered; + } + + private static void benchmarkFilter() { + var bean = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + long thread = Thread.currentThread().getId(); + for (int count : new int[]{66, 258, 1026}) { + List quads = new ArrayList<>(); + for (int i = 0; i < count; i++) quads.add(new BakedQuad(new int[28], 0, i == 0 ? Direction.SOUTH : Direction.NORTH)); + BakedModel model = model(quads); + for (int i = 0; i < 20000; i++) { + result = referenceFilter(quads); + result = SpriteQuadProvider.get(model, quads); + } + long before = bean.getThreadAllocatedBytes(thread); + long start = System.nanoTime(); + for (int i = 0; i < 100000; i++) result = referenceFilter(quads); + long referenceTime = System.nanoTime() - start; + long referenceBytes = bean.getThreadAllocatedBytes(thread) - before; + before = bean.getThreadAllocatedBytes(thread); + start = System.nanoTime(); + for (int i = 0; i < 100000; i++) result = SpriteQuadProvider.get(model, quads); + long cachedTime = System.nanoTime() - start; + long cachedBytes = bean.getThreadAllocatedBytes(thread) - before; + check(cachedBytes < referenceBytes / 100, "warm cache must eliminate at least 99% of filter allocation"); + System.out.printf(java.util.Locale.ROOT, + "SPRITE_FILTER quads=%d old_bytes/op=%.2f cached_bytes/op=%.2f old_ns/op=%.2f cached_ns/op=%.2f%n", + count, referenceBytes / 100000., cachedBytes / 100000., referenceTime / 100000., cachedTime / 100000.); + } + } + + private static void check(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/versions/1.8.9/src/test/java/io/github/axolotlclient/test/mixin/GlintPassCounterMixin.java b/versions/1.8.9/src/test/java/io/github/axolotlclient/test/mixin/GlintPassCounterMixin.java new file mode 100644 index 000000000..97387bca1 --- /dev/null +++ b/versions/1.8.9/src/test/java/io/github/axolotlclient/test/mixin/GlintPassCounterMixin.java @@ -0,0 +1,40 @@ +/* + * Copyright © 2026 DSNS + * + * This file is part of DolphinClient. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * For more information, see the LICENSE file. + */ + +package io.github.axolotlclient.test.mixin; + +import io.github.axolotlclient.oldanimations.GlintRenderChecks; + +import net.minecraft.client.render.entity.ItemRenderer; +import net.minecraft.client.resource.model.BakedModel; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ItemRenderer.class) +public abstract class GlintPassCounterMixin { + @Inject(method = "render(Lnet/minecraft/client/resource/model/BakedModel;I)V", at = @At("HEAD")) + private void countGlintPass(BakedModel model, int color, CallbackInfo ci) { + GlintRenderChecks.vanillaPasses++; + } +} diff --git a/versions/1.8.9/src/test/resources/dolphinclient-test.mixins.json b/versions/1.8.9/src/test/resources/dolphinclient-test.mixins.json new file mode 100644 index 000000000..ca4a1304e --- /dev/null +++ b/versions/1.8.9/src/test/resources/dolphinclient-test.mixins.json @@ -0,0 +1,7 @@ +{ + "required": true, + "package": "io.github.axolotlclient.test.mixin", + "compatibilityLevel": "JAVA_17", + "client": ["GlintPassCounterMixin"], + "injectors": { "defaultRequire": 1 } +} diff --git a/versions/1.8.9/src/test/resources/fabric.mod.json b/versions/1.8.9/src/test/resources/fabric.mod.json index 34ff2f366..755fac5b2 100644 --- a/versions/1.8.9/src/test/resources/fabric.mod.json +++ b/versions/1.8.9/src/test/resources/fabric.mod.json @@ -23,6 +23,7 @@ "license": "LGPL-3.0-or-later", "icon": "assets/dolphinclient/icon.png", "environment": "client", + "mixins": ["dolphinclient-test.mixins.json"], "entrypoints": { "client": [ "io.github.axolotlclient.DolphinClientTest"