From aea7d0e66212e958028e3fe400eb92ca099a5584 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 14 Nov 2026 19:01:05 -0500 Subject: [PATCH] perf: entity culling prefetch, sprite mip upscale, mod hotspot fixes Prefetch Entity Culling block entity lookups, upscale small sprites to keep atlas mip levels, cache section air counts, gate Sodium Extra expanded cylindrical traversal, cache TierTagger mode, keep both GPUs visible to Vulkan on switchable laptops. --- .../client/perf/BlockEntityCullBox.java | 4 + .../client/perf/ChunkPrefetchCache.java | 21 +++ .../polydium/client/perf/UpscaledSprite.java | 7 + .../polydium/early/EarlyVulkanPreLaunch.java | 44 ++++++ .../polydium/mixin/PolydiumMixinPlugin.java | 68 ++++++++- .../MixinEntityCullingBlockEntityBox.java | 11 +- .../perf/MixinEntityCullingPrefetch.java | 81 ++++++++++ .../perf/MixinEntityCullingPrefetchChunk.java | 47 ++++++ .../client/perf/MixinItemModelUpscale.java | 34 +++++ .../client/perf/MixinSectionAirCount.java | 37 +++++ .../perf/MixinSpriteContentsUpscale.java | 22 +++ .../client/perf/MixinSpriteMipUpscale.java | 139 ++++++++++++++++++ .../client/perf/SpriteContentsAccessor.java | 32 ++++ .../MixinExpandedCylindricalGate.java | 9 ++ .../tiertagger/MixinTierTaggerModeCache.java | 41 ++++++ src/main/resources/mixins.polydium.json | 12 ++ .../polydium/test/ChunkPrefetchCacheTest.kt | 31 ++++ .../test/ExpandedCylindricalGateTest.kt | 112 ++++++++++++++ 18 files changed, 745 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/polyfrost/polydium/client/perf/ChunkPrefetchCache.java create mode 100644 src/main/java/org/polyfrost/polydium/client/perf/UpscaledSprite.java create mode 100644 src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetch.java create mode 100644 src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetchChunk.java create mode 100644 src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinItemModelUpscale.java create mode 100644 src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSectionAirCount.java create mode 100644 src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteContentsUpscale.java create mode 100644 src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteMipUpscale.java create mode 100644 src/main/java/org/polyfrost/polydium/mixin/client/perf/SpriteContentsAccessor.java create mode 100644 src/main/java/org/polyfrost/polydium/mixin/client/perf/sodiumextra/MixinExpandedCylindricalGate.java create mode 100644 src/main/java/org/polyfrost/polydium/mixin/client/perf/tiertagger/MixinTierTaggerModeCache.java create mode 100644 src/test/kotlin/org/polyfrost/polydium/test/ChunkPrefetchCacheTest.kt create mode 100644 src/test/kotlin/org/polyfrost/polydium/test/ExpandedCylindricalGateTest.kt diff --git a/src/main/java/org/polyfrost/polydium/client/perf/BlockEntityCullBox.java b/src/main/java/org/polyfrost/polydium/client/perf/BlockEntityCullBox.java index 97910c2..48c88a3 100644 --- a/src/main/java/org/polyfrost/polydium/client/perf/BlockEntityCullBox.java +++ b/src/main/java/org/polyfrost/polydium/client/perf/BlockEntityCullBox.java @@ -3,7 +3,11 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.phys.AABB; +import java.util.concurrent.atomic.AtomicInteger; + public interface BlockEntityCullBox { + AtomicInteger RENDERER_EPOCH = new AtomicInteger(1); + ClassValue FIXED_BOUNDS = new ClassValue<>() { @Override protected Boolean computeValue(Class renderer) { diff --git a/src/main/java/org/polyfrost/polydium/client/perf/ChunkPrefetchCache.java b/src/main/java/org/polyfrost/polydium/client/perf/ChunkPrefetchCache.java new file mode 100644 index 0000000..0193f76 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/client/perf/ChunkPrefetchCache.java @@ -0,0 +1,21 @@ +package org.polyfrost.polydium.client.perf; + +/** + * Entity Culling's filtered block entity list for one chunk, valid while the chunk's block entity map and the + * generation (renderer reloads, whitelist edits) are unchanged. + */ +public interface ChunkPrefetchCache { + Object[] polydium$storedPrefetch(); + + int polydium$prefetchGeneration(); + + void polydium$setPrefetch(Object[] cullables, int generation); + + default Object[] polydium$prefetch(int generation) { + return generation == this.polydium$prefetchGeneration() ? this.polydium$storedPrefetch() : null; + } + + default void polydium$invalidatePrefetch() { + this.polydium$setPrefetch(null, 0); + } +} diff --git a/src/main/java/org/polyfrost/polydium/client/perf/UpscaledSprite.java b/src/main/java/org/polyfrost/polydium/client/perf/UpscaledSprite.java new file mode 100644 index 0000000..14fa63a --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/client/perf/UpscaledSprite.java @@ -0,0 +1,7 @@ +package org.polyfrost.polydium.client.perf; + +public interface UpscaledSprite { + int polydium$upscale(); + + void polydium$setUpscale(int factor); +} diff --git a/src/main/java/org/polyfrost/polydium/early/EarlyVulkanPreLaunch.java b/src/main/java/org/polyfrost/polydium/early/EarlyVulkanPreLaunch.java index 051b446..955fba9 100644 --- a/src/main/java/org/polyfrost/polydium/early/EarlyVulkanPreLaunch.java +++ b/src/main/java/org/polyfrost/polydium/early/EarlyVulkanPreLaunch.java @@ -4,12 +4,24 @@ //? if >= 1.21.11 { import oshi.SystemInfo; import oshi.hardware.GraphicsCard; +import org.lwjgl.system.APIUtil; +import org.lwjgl.system.JNI; +import org.lwjgl.system.SharedLibrary; + +import java.io.IOException; +import java.nio.ByteBuffer; + +import static org.lwjgl.system.MemoryUtil.memAddress; +import static org.lwjgl.system.MemoryUtil.memFree; +import static org.lwjgl.system.MemoryUtil.memUTF16; //?} // Kept apart from EarlyVulkan: the JVM remembers a failed class lookup per calling class, and EarlyVulkan loads before oshi can public final class EarlyVulkanPreLaunch implements PreLaunchEntrypoint { @Override public void onPreLaunch() { + //? if >= 1.21.11 + showSwitchableGpus(); detect(/*? if >= 26.2 {*/true/*?} else {*//*false*//*?}*/); } @@ -33,4 +45,36 @@ private void detect(boolean decide) { } //?} } + + //? if >= 1.21.11 { + // NVIDIA's and AMD's implicit switchable-graphics Vulkan layers hide the dGPU from vkEnumeratePhysicalDevices + // when their driver profile picks the iGPU, even with the Windows GPU preference set to high performance. + // The loader reads these with GetEnvironmentVariable at vkCreateInstance, so setting them here is early enough. + // An explicit Windows "Power saving" choice for this executable is kept, since unhiding the dGPU would override it. + private static void showSwitchableGpus() { + if (!System.getProperty("os.name", "").startsWith("Windows")) + return; + try (SharedLibrary kernel32 = APIUtil.apiCreateLibrary("kernel32")) { + if (windowsPrefersPowerSaving()) + return; + long setEnv = kernel32.getFunctionAddress("SetEnvironmentVariableW"); + for (String name : new String[]{"DISABLE_LAYER_NV_OPTIMUS_1", "DISABLE_LAYER_AMD_SWITCHABLE_GRAPHICS_1"}) { + ByteBuffer key = memUTF16(name); + ByteBuffer value = memUTF16("1"); + JNI.callPPI(memAddress(key), memAddress(value), setEnv); + memFree(key); + memFree(value); + } + } catch (Throwable ignored) { + } + } + + private static boolean windowsPrefersPowerSaving() throws IOException { + String exe = ProcessHandle.current().info().command().orElse(null); + if (exe == null) + return false; + Process reg = new ProcessBuilder("reg", "query", "HKCU\\Software\\Microsoft\\DirectX\\UserGpuPreferences", "/v", exe).redirectErrorStream(true).start(); + return new String(reg.getInputStream().readAllBytes()).contains("GpuPreference=1;"); + } + //?} } diff --git a/src/main/java/org/polyfrost/polydium/mixin/PolydiumMixinPlugin.java b/src/main/java/org/polyfrost/polydium/mixin/PolydiumMixinPlugin.java index a781b51..b19e0c2 100644 --- a/src/main/java/org/polyfrost/polydium/mixin/PolydiumMixinPlugin.java +++ b/src/main/java/org/polyfrost/polydium/mixin/PolydiumMixinPlugin.java @@ -250,10 +250,13 @@ private static boolean standsDown(String mixinClassName) { Map.entry("OcclusionCull", new String[]{"OcclusionRegion", "OcclusionListAccessor"}), Map.entry("ImmediateDrawRing", new String[]{"ImmediateDrawRingSeparate", "ImmediateDrawRingEmulated", "ImmediateDrawRingFrame", "ImmediateDrawRingClose"}), Map.entry("EntityCullingBlockEntityBox", new String[]{"MixinBlockEntityCullBox"}), + Map.entry("EntityCullingPrefetch", new String[]{"EntityCullingBlockEntityBox", "MixinBlockEntityCullBox", "EntityCullingPrefetchChunk"}), Map.entry("ParticleLightBatch", new String[]{"ParticleLightDrain", "ParticleLightSourceInvoker", "ParticleLevelAccessor"}), Map.entry("ParticleCoreAsyncThreshold", new String[]{"ParticleSoundClientThread"}), Map.entry("PreparedFrameRecover", new String[]{"PreparedFrameAccessor", "StagedVertexBufferAccessor"}), Map.entry("IrisShadowOutlineDrop", new String[]{"BufferSourceAccessor", "OutlineBufferSourceAccessor"}), + Map.entry("SpriteMipUpscale", new String[]{"SpriteContentsAccessor", "SpriteContentsUpscale"}), + Map.entry("ItemModelUpscale", new String[]{"SpriteContentsUpscale"}), //? if < 1.21.10 { /*Map.entry("OpaqueSpriteParticle", new String[]{"SpriteContentsTransparency", "OpaqueParticleRenderOrder"}), *///?} else { @@ -435,6 +438,12 @@ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { if (mixinClassName.endsWith(".MixinLeafCullScratchPos") && !Mods.loaded("cull-fewer-leaves")) return false; + if (mixinClassName.endsWith(".MixinExpandedCylindricalGate") && !Mods.loaded("sodium-extra")) + return false; + + if (mixinClassName.endsWith(".MixinTierTaggerModeCache") && !Mods.loaded("tiertagger") && !Mods.loaded("tier-tagger")) + return false; + if ((mixinClassName.endsWith(".MixinClientLightBudget") || mixinClassName.endsWith(".MixinClientLightBudgetTick") || mixinClassName.endsWith(".MixinChunkLoadDirtyOnce")) && !Mods.loaded("scalablelux")) return false; @@ -463,7 +472,7 @@ public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { if (mixinClassName.endsWith(".MixinReplayEntityTickets") && !Mods.loaded("flashback")) return false; - if ((mixinClassName.endsWith(".MixinEntityCullingBlockEntityBox") || mixinClassName.endsWith(".MixinBlockEntityCullBox")) && !Mods.loaded("entityculling")) + if ((mixinClassName.endsWith(".MixinEntityCullingBlockEntityBox") || mixinClassName.endsWith(".MixinBlockEntityCullBox") || mixinClassName.contains(".MixinEntityCullingPrefetch")) && !Mods.loaded("entityculling")) return false; if (mixinClassName.endsWith(".MixinBlockModelHookPrecheck") && !Mods.loaded("obe") && !Mods.loaded("overflowanimations")) @@ -644,6 +653,13 @@ public void postApply(String targetClassName, ClassNode targetClass, String mixi return; } + if (mixinClassName.endsWith(".MixinExpandedCylindricalGate")) { + for (MethodNode method : targetClass.methods) + if (method.name.equals("processQueue")) + gateExpandedCylindricalTraversal(targetClass, method); + return; + } + if (mixinClassName.endsWith(".MixinClientLightBudget")) { for (MethodNode method : targetClass.methods) if (method.name.equals("propagateChanges") && method.desc.equals("()V")) @@ -1488,6 +1504,56 @@ private static void chunkLoadDirtyOnce(ClassNode owner, MethodNode method) { } } + // Sodium Extra's @ModifyArgs on visitNeighbors boxes every argument into an Args object for each section the occlusion + // search visits, even though its handler returns untouched unless expanded cylindrical fog culling is active. Mixin + // emits `Args$N.of(args)`, the handler call, then Args unpacking right before the original call, with the same stack + // before `of` as at the call, so the handler's own early-out can jump straight to the plain call. + public static void gateExpandedCylindricalTraversal(ClassNode owner, MethodNode method) { + String fog = "me/flashyreese/mods/sodiumextra/client/fog/FogDistanceHelper"; + for (AbstractInsnNode insn : method.instructions.toArray()) { + if (!(insn instanceof MethodInsnNode handler) || !handler.owner.equals(owner.name) || !handler.name.endsWith("allowExpandedCylindricalVerticalTraversal") + || !handler.desc.equals("(Lorg/spongepowered/asm/mixin/injection/invoke/arg/Args;)V")) + continue; + AbstractInsnNode pack = handler.getPrevious(); + while (pack instanceof InsnNode || pack instanceof VarInsnNode) + pack = pack.getPrevious(); + if (!(pack instanceof MethodInsnNode of) || of.getOpcode() != Opcodes.INVOKESTATIC || !of.name.equals("of")) + continue; + AbstractInsnNode next = handler.getNext(); + while (next instanceof InsnNode || next instanceof MethodInsnNode call && call.owner.equals(of.owner)) + next = next.getNext(); + if (!(next instanceof MethodInsnNode neighbors) || !neighbors.name.equals("visitNeighbors") + || !Arrays.equals(Type.getArgumentTypes(neighbors.desc), Type.getArgumentTypes(of.desc))) + continue; + + LabelNode plain = new LabelNode(); + InsnList gate = new InsnList(); + if (handler.getOpcode() == Opcodes.INVOKESTATIC) { + Type[] params = Type.getArgumentTypes(method.desc); + if ((method.access & Opcodes.ACC_STATIC) == 0 || params.length < 3 || !params[2].equals(Type.FLOAT_TYPE)) + continue; + gate.add(new VarInsnNode(Opcodes.FLOAD, 2)); + gate.add(new MethodInsnNode(Opcodes.INVOKESTATIC, fog, "isExpandedCylindricalCullDistance", "(F)Z", false)); + gate.add(new JumpInsnNode(Opcodes.IFEQ, plain)); + } else { + String[] distances = {"searchDistanceRegular", "searchDistanceLocal"}; + if ((method.access & Opcodes.ACC_STATIC) != 0 || !Arrays.stream(distances).allMatch(name -> owner.fields.stream().anyMatch(field -> field.name.equals(name) && field.desc.equals("F")))) + continue; + LabelNode run = new LabelNode(); + for (String name : distances) { + gate.add(new VarInsnNode(Opcodes.ALOAD, 0)); + gate.add(new FieldInsnNode(Opcodes.GETFIELD, owner.name, name, "F")); + gate.add(new MethodInsnNode(Opcodes.INVOKESTATIC, fog, "isExpandedCylindricalCullDistance", "(F)Z", false)); + gate.add(new JumpInsnNode(Opcodes.IFNE, run)); + } + gate.add(new JumpInsnNode(Opcodes.GOTO, plain)); + gate.add(run); + } + method.instructions.insertBefore(of, gate); + method.instructions.insertBefore(neighbors, plain); + } + } + private static void budgetLightTasks(ClassNode owner, MethodNode method) { if (owner.fields.stream().noneMatch(field -> field.name.equals("isClientSide") && field.desc.equals("Z"))) return; diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingBlockEntityBox.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingBlockEntityBox.java index 8b7faf5..8124083 100644 --- a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingBlockEntityBox.java +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingBlockEntityBox.java @@ -29,20 +29,18 @@ public abstract class MixinEntityCullingBlockEntityBox { @Shadow public abstract BlockEntityRenderer getRenderer(E blockEntity); - @Unique - private int polydium$rendererEpoch = 1; @Unique private BlockEntity polydium$cullTarget; @Inject(method = "onResourceManagerReload", at = @At("RETURN")) private void polydium$invalidateCullBoxes(CallbackInfo ci) { - this.polydium$rendererEpoch++; + BlockEntityCullBox.RENDERER_EPOCH.incrementAndGet(); } @TargetHandler(mixin = POLYDIUM$MIXIN, name = "tryExtractRenderState") @WrapOperation(method = "@MixinSquared:Handler", at = @At(value = "INVOKE", target = "Ldev/tr7zw/entityculling/EntityCullingModBase;setupAABB(Lnet/minecraft/world/level/block/entity/BlockEntity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/phys/AABB;"), require = 0, expect = 0) private AABB polydium$inline$cachedBox(@Coerce Object mod, BlockEntity blockEntity, BlockPos pos, Operation original) { - AABB cached = ((BlockEntityCullBox) blockEntity).polydium$cullBox(this.polydium$rendererEpoch); + AABB cached = ((BlockEntityCullBox) blockEntity).polydium$cullBox(BlockEntityCullBox.RENDERER_EPOCH.get()); this.polydium$cullTarget = cached == null ? blockEntity : null; return cached != null ? cached : original.call(mod, blockEntity, pos); } @@ -60,9 +58,10 @@ public abstract class MixinEntityCullingBlockEntityBox { if (blockEntity != null) { this.polydium$cullTarget = null; BlockEntityCullBox holder = (BlockEntityCullBox) blockEntity; - if (holder.polydium$cullBoxEpoch() != this.polydium$rendererEpoch) { + int epoch = BlockEntityCullBox.RENDERER_EPOCH.get(); + if (holder.polydium$cullBoxEpoch() != epoch) { boolean fixed = BlockEntityCullBox.fixedBounds(this.getRenderer(blockEntity).getClass()); - holder.polydium$setCullBox(fixed ? box : null, this.polydium$rendererEpoch); + holder.polydium$setCullBox(fixed ? box : null, epoch); } } return frustum.isVisible(box); diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetch.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetch.java new file mode 100644 index 0000000..b9f4d9e --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetch.java @@ -0,0 +1,81 @@ +package org.polyfrost.polydium.mixin.client.perf; + +//? if >= 1.21.10 { +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Local; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.chunk.LevelChunk; +import org.polyfrost.polydium.client.perf.BlockEntityCullBox; +import org.polyfrost.polydium.client.perf.ChunkPrefetchCache; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Coerce; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Entity Culling rescans every block entity in 17x17 chunks each tick; reuse a chunk's filtered list until it changes +@Pseudo +@Mixin(targets = "dev.tr7zw.entityculling.EntityCullingModBase", remap = false) +public abstract class MixinEntityCullingPrefetch { + @Shadow + public Set> blockEntityWhitelist; + + @Unique + private int polydium$generation; + @Unique + private int polydium$rendererEpoch; + @Unique + private Set> polydium$whitelist = Set.of(); + + @Inject(method = "extractEntityData", at = @At("HEAD"), require = 0, expect = 0) + private void polydium$checkPrefetchInputs(CallbackInfo ci) { + if (!Minecraft.getInstance().isSameThread()) + return; + int epoch = BlockEntityCullBox.RENDERER_EPOCH.get(); + // The config screen edits the whitelist in place + if (epoch != this.polydium$rendererEpoch || !this.polydium$whitelist.equals(this.blockEntityWhitelist)) { + this.polydium$rendererEpoch = epoch; + this.polydium$whitelist = Set.copyOf(this.blockEntityWhitelist); + this.polydium$generation++; + } + } + + @WrapOperation(method = "extractEntityData", at = @At(value = "INVOKE", target = "Ldev/tr7zw/entityculling/EntityCullingModBase;prefetchBlockEntityData(Ljava/util/List;Ljava/util/Map;)V"), require = 0, expect = 0) + private void polydium$cachedPrefetch(@Coerce Object mod, List out, Map blockEntities, Operation original, @Local LevelChunk chunk) { + Minecraft client = Minecraft.getInstance(); + // With safe mode off this runs on the cull thread, racing chunk updates + if (!client.isSameThread()) { + original.call(mod, out, blockEntities); + return; + } + ChunkPrefetchCache cache = (ChunkPrefetchCache) chunk; + Object[] cached = cache.polydium$prefetch(this.polydium$generation); + if (cached != null) { + for (Object cullable : cached) + out.add(cullable); + return; + } + int start = out.size(); + original.call(mod, out, blockEntities); + List added = out.subList(start, out.size()); + BlockEntityRenderDispatcher dispatcher = client.getBlockEntityRenderDispatcher(); + // A renderer with its own getRenderBoundingBox may move its box every tick, so that chunk is rebuilt each time + for (Object cullable : added) { + if (!BlockEntityCullBox.fixedBounds(dispatcher.getRenderer((BlockEntity) cullable).getClass())) + return; + } + cache.polydium$setPrefetch(added.toArray(), this.polydium$generation); + } +} +//?} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetchChunk.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetchChunk.java new file mode 100644 index 0000000..3d71087 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinEntityCullingPrefetchChunk.java @@ -0,0 +1,47 @@ +package org.polyfrost.polydium.mixin.client.perf; + +//? if >= 1.21.10 { +import net.minecraft.world.level.chunk.LevelChunk; +import org.polyfrost.polydium.client.perf.ChunkPrefetchCache; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +// These are the only LevelChunk methods that change the block entity map's contents on the client +@Mixin(LevelChunk.class) +public abstract class MixinEntityCullingPrefetchChunk implements ChunkPrefetchCache { + @Unique + private Object[] polydium$prefetch; + @Unique + private int polydium$prefetchGeneration; + + @Override + public Object[] polydium$storedPrefetch() { + return this.polydium$prefetch; + } + + @Override + public int polydium$prefetchGeneration() { + return this.polydium$prefetchGeneration; + } + + @Override + public void polydium$setPrefetch(Object[] cullables, int generation) { + this.polydium$prefetch = cullables; + this.polydium$prefetchGeneration = generation; + } + + @Inject(method = {"setBlockEntity", "removeBlockEntity", "clearAllBlockEntities"}, at = @At("HEAD")) + private void polydium$invalidateOnChange(CallbackInfo ci) { + this.polydium$invalidatePrefetch(); + } + + @Inject(method = "getBlockEntity(Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/chunk/LevelChunk$EntityCreationType;)Lnet/minecraft/world/level/block/entity/BlockEntity;", at = @At(value = "INVOKE", target = "Ljava/util/Map;remove(Ljava/lang/Object;)Ljava/lang/Object;", ordinal = 1)) + private void polydium$invalidateOnDrop(CallbackInfoReturnable cir) { + this.polydium$invalidatePrefetch(); + } +} +//?} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinItemModelUpscale.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinItemModelUpscale.java new file mode 100644 index 0000000..40d09a4 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinItemModelUpscale.java @@ -0,0 +1,34 @@ +package org.polyfrost.polydium.mixin.client.perf; + +//? if >= 1.21.11 { +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import net.minecraft.client./*? if >= 26.1 {*/resources.model.cuboid/*?} else {*//*renderer.block.model*//*?}*/.ItemModelGenerator; +import net.minecraft.client.renderer.texture.SpriteContents; +import org.polyfrost.polydium.client.perf.UpscaledSprite; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +/** + * Generated item models get one side quad per edge pixel, so a sprite scaled up for mipmapping would multiply them + * by its scale factor. Side faces are built on the sprite's unscaled pixel grid instead, which gives the same shape. + */ +@Mixin(ItemModelGenerator.class) +public abstract class MixinItemModelUpscale { + @WrapOperation(method = {"getSideFaces", /*? if >= 26.1 {*/"bakeSideFaces"/*?} else {*//*"createSideElements"*//*?}*/}, at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/texture/SpriteContents;width()I")) + private static int polydium$unscaledWidth(SpriteContents sprite, Operation original) { + return original.call(sprite) / ((UpscaledSprite) sprite).polydium$upscale(); + } + + @WrapOperation(method = {"getSideFaces", /*? if >= 26.1 {*/"bakeSideFaces"/*?} else {*//*"createSideElements"*//*?}*/}, at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/texture/SpriteContents;height()I")) + private static int polydium$unscaledHeight(SpriteContents sprite, Operation original) { + return original.call(sprite) / ((UpscaledSprite) sprite).polydium$upscale(); + } + + @WrapOperation(method = "isTransparent", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/texture/SpriteContents;isTransparent(III)Z")) + private static boolean polydium$unscaledTransparent(SpriteContents sprite, int frame, int x, int y, Operation original) { + int factor = ((UpscaledSprite) sprite).polydium$upscale(); + return original.call(sprite, frame, x * factor, y * factor); + } +} +//?} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSectionAirCount.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSectionAirCount.java new file mode 100644 index 0000000..0ed2d2e --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSectionAirCount.java @@ -0,0 +1,37 @@ +package org.polyfrost.polydium.mixin.client.perf; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.PalettedContainer; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * The client trusts the block count a server writes in front of each chunk section, and both vanilla and Sodium skip + * meshing any section whose count is zero. Servers that build chunk packets themselves (arena instancing, spectator + * cameras, protocol translators) sometimes send zero for sections that hold blocks, which leaves sky-coloured holes + * until the chunk is resent. A zero count with a palette that still names a non-air state is recounted from the data. + */ +@Mixin(LevelChunkSection.class) +public abstract class MixinSectionAirCount { + @Shadow + private short nonEmptyBlockCount; + + @Shadow + @Final + private PalettedContainer states; + + @Shadow + public abstract void recalcBlockCounts(); + + @Inject(method = "read", at = @At("TAIL")) + private void polydium$recountEmptyClaim(FriendlyByteBuf buffer, CallbackInfo ci) { + if (this.nonEmptyBlockCount == 0 && this.states.maybeHas(state -> !state.isAir())) + this.recalcBlockCounts(); + } +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteContentsUpscale.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteContentsUpscale.java new file mode 100644 index 0000000..685d381 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteContentsUpscale.java @@ -0,0 +1,22 @@ +package org.polyfrost.polydium.mixin.client.perf; + +import net.minecraft.client.renderer.texture.SpriteContents; +import org.polyfrost.polydium.client.perf.UpscaledSprite; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +@Mixin(SpriteContents.class) +public abstract class MixinSpriteContentsUpscale implements UpscaledSprite { + @Unique + private int polydium$upscale = 1; + + @Override + public int polydium$upscale() { + return this.polydium$upscale; + } + + @Override + public void polydium$setUpscale(int factor) { + this.polydium$upscale = factor; + } +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteMipUpscale.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteMipUpscale.java new file mode 100644 index 0000000..a3c0861 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/MixinSpriteMipUpscale.java @@ -0,0 +1,139 @@ +package org.polyfrost.polydium.mixin.client.perf; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.mojang.blaze3d.platform.NativeImage; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import net.minecraft.ReportedException; +import net.minecraft.client.renderer.texture.SpriteContents; +import net.minecraft.client.renderer.texture.SpriteLoader; +import net.minecraft.client.renderer.texture.StitcherException; +import org.polyfrost.polydium.client.perf.UpscaledSprite; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +/** + * Vanilla lowers the mip level of a whole atlas to fit its least power-of-two sprite, so one odd-sized texture from a + * resource pack (a 3x4 vine leaf, say) turns off mipmaps for every block and distant terrain aliases. Static sprites + * that would cap the level are scaled up by an integer factor with nearest sampling instead, which looks the same at + * mip 0 because sprite UVs are normalised to the sprite's atlas rectangle. + */ +@Mixin(SpriteLoader.class) +public abstract class MixinSpriteMipUpscale { + @Unique + private static final int MAX_SIZE = 1024; + // 256 MiB of RGBA across all scaled copies, which stay alive with the atlas + @Unique + private static final long MAX_SCALED_PIXELS = 1L << 26; + + // Upscaling raises the atlas mip level, and with it the per-sprite padding, so an atlas that fit before can overflow. + // Then the original images go back and vanilla stitches them as it would have without this. + @WrapMethod(method = "stitch") + private SpriteLoader.Preparations polydium$upscaleCappingSprites(List sprites, int maxMipmapLevels, Executor executor, Operation original) { + int target = maxMipmapLevels; + int lowestUnscaled = 1 << maxMipmapLevels; + + for (SpriteContents sprite : sprites) { + int lowest = polydium$lowestOneBit(sprite); + lowestUnscaled = Math.min(lowestUnscaled, lowest); + if (lowest < 1 << maxMipmapLevels) + target = Math.min(target, Integer.numberOfTrailingZeros(lowest * polydium$maxFactor(sprite))); + } + + if (target <= Integer.numberOfTrailingZeros(lowestUnscaled)) + return original.call(sprites, maxMipmapLevels, executor); + + int needed = 1 << target; + long pixels = 0; + for (SpriteContents sprite : sprites) { + int factor = needed / polydium$lowestOneBit(sprite); + if (factor > 1) + pixels += (long) sprite.width() * sprite.height() * factor * factor; + } + + if (pixels > MAX_SCALED_PIXELS) + return original.call(sprites, maxMipmapLevels, executor); + + List scaledSprites = new ArrayList<>(); + List sources = new ArrayList<>(); + + try { + for (SpriteContents sprite : sprites) { + int lowest = polydium$lowestOneBit(sprite); + if (lowest >= needed) + continue; + + int factor = needed / lowest; + SpriteContentsAccessor access = (SpriteContentsAccessor) sprite; + NativeImage source = access.polydium$originalImage(); + NativeImage scaled = new NativeImage(sprite.width() * factor, sprite.height() * factor, false); + polydium$setImage(access, scaled); + ((UpscaledSprite) sprite).polydium$setUpscale(factor); + scaledSprites.add(sprite); + sources.add(source); + + for (int y = 0; y < scaled.getHeight(); y++) { + for (int x = 0; x < scaled.getWidth(); x++) { + //? if >= 1.21.4 { + scaled.setPixel(x, y, source.getPixel(x / factor, y / factor)); + //?} else { + /*scaled.setPixelRGBA(x, y, source.getPixelRGBA(x / factor, y / factor)); + *///?} + } + } + } + } catch (RuntimeException e) { + polydium$restore(scaledSprites, sources); + return original.call(sprites, maxMipmapLevels, executor); + } + + SpriteLoader.Preparations result; + try { + result = original.call(sprites, maxMipmapLevels, executor); + } catch (ReportedException e) { + if (!(e.getCause() instanceof StitcherException)) + throw e; + polydium$restore(scaledSprites, sources); + return original.call(sprites, maxMipmapLevels, executor); + } + + sources.forEach(NativeImage::close); + return result; + } + + @Unique + private static void polydium$restore(List scaledSprites, List sources) { + for (int i = 0; i < scaledSprites.size(); i++) { + SpriteContentsAccessor access = (SpriteContentsAccessor) scaledSprites.get(i); + NativeImage scaled = access.polydium$originalImage(); + polydium$setImage(access, sources.get(i)); + ((UpscaledSprite) scaledSprites.get(i)).polydium$setUpscale(1); + scaled.close(); + } + } + + @Unique + private static int polydium$lowestOneBit(SpriteContents sprite) { + return Math.min(Integer.lowestOneBit(sprite.width()), Integer.lowestOneBit(sprite.height())); + } + + @Unique + private static int polydium$maxFactor(SpriteContents sprite) { + SpriteContentsAccessor access = (SpriteContentsAccessor) sprite; + NativeImage source = access.polydium$originalImage(); + int size = Math.max(sprite.width(), sprite.height()); + if (access.polydium$frameCount() > 1 || size > MAX_SIZE || source.getWidth() != sprite.width() || source.getHeight() != sprite.height() || source.format() != NativeImage.Format.RGBA) + return 1; + return Integer.highestOneBit(MAX_SIZE / size); + } + + @Unique + private static void polydium$setImage(SpriteContentsAccessor access, NativeImage image) { + access.polydium$setWidth(image.getWidth()); + access.polydium$setHeight(image.getHeight()); + access.polydium$setOriginalImage(image); + access.polydium$setByMipLevel(new NativeImage[]{image}); + } +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/SpriteContentsAccessor.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/SpriteContentsAccessor.java new file mode 100644 index 0000000..c0d2e04 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/SpriteContentsAccessor.java @@ -0,0 +1,32 @@ +package org.polyfrost.polydium.mixin.client.perf; + +import com.mojang.blaze3d.platform.NativeImage; +import net.minecraft.client.renderer.texture.SpriteContents; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(SpriteContents.class) +public interface SpriteContentsAccessor { + @Accessor("originalImage") + NativeImage polydium$originalImage(); + + @Mutable + @Accessor("originalImage") + void polydium$setOriginalImage(NativeImage image); + + @Accessor("byMipLevel") + void polydium$setByMipLevel(NativeImage[] images); + + @Mutable + @Accessor("width") + void polydium$setWidth(int width); + + @Mutable + @Accessor("height") + void polydium$setHeight(int height); + + @Invoker("getFrameCount") + int polydium$frameCount(); +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/sodiumextra/MixinExpandedCylindricalGate.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/sodiumextra/MixinExpandedCylindricalGate.java new file mode 100644 index 0000000..fa2a7a3 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/sodiumextra/MixinExpandedCylindricalGate.java @@ -0,0 +1,9 @@ +package org.polyfrost.polydium.mixin.client.perf.sodiumextra; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; + +@Pseudo +@Mixin(targets = "net.caffeinemc.mods.sodium.client.render.chunk.occlusion.OcclusionCuller", remap = false, priority = 1100) +public abstract class MixinExpandedCylindricalGate { +} diff --git a/src/main/java/org/polyfrost/polydium/mixin/client/perf/tiertagger/MixinTierTaggerModeCache.java b/src/main/java/org/polyfrost/polydium/mixin/client/perf/tiertagger/MixinTierTaggerModeCache.java new file mode 100644 index 0000000..b76a1c0 --- /dev/null +++ b/src/main/java/org/polyfrost/polydium/mixin/client/perf/tiertagger/MixinTierTaggerModeCache.java @@ -0,0 +1,41 @@ +package org.polyfrost.polydium.mixin.client.perf.tiertagger; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +// TierTagger streams its gamemode list on every Player.getDisplayName, several times per player per frame. +// The list only changes in init(), which swaps in a fresh cache so lookups that raced it can't stick. +@Pseudo +@Mixin(targets = {"net.uku3lig.tiertagger.TierCache", "com.kevin.tiertagger.TierCache"}, remap = false) +public class MixinTierTaggerModeCache { + @Unique + private static volatile Map> polydium$modes = new ConcurrentHashMap<>(); + + @SuppressWarnings({"rawtypes", "unchecked"}) + @WrapMethod(method = "findMode", remap = false, require = 0) + private static Optional polydium$cachedMode(String id, Operation original) { + if (id == null) return original.call(id); + Map> modes = polydium$modes; + Optional mode = modes.get(id); + if (mode == null) { + mode = original.call(id); + modes.put(id, mode); + } + return mode; + } + + @Inject(method = "init", at = @At("RETURN"), remap = false, require = 0) + private static void polydium$resetModes(CallbackInfo ci) { + polydium$modes = new ConcurrentHashMap<>(); + } +} diff --git a/src/main/resources/mixins.polydium.json b/src/main/resources/mixins.polydium.json index 25a785a..1f241f8 100644 --- a/src/main/resources/mixins.polydium.json +++ b/src/main/resources/mixins.polydium.json @@ -37,6 +37,12 @@ //? if >= 1.21.1 "client.perf.MixinPaneCapCull", "client.perf.CameraNearPlaneAccessor", + "client.perf.MixinSectionAirCount", + "client.perf.MixinSpriteMipUpscale", + "client.perf.SpriteContentsAccessor", + "client.perf.MixinSpriteContentsUpscale", + //? if >= 1.21.11 + "client.perf.MixinItemModelUpscale", //? if >= 1.21.8 "client.perf.ItemBoundsLayerAccessor", //? if >= 1.21.10 @@ -798,6 +804,10 @@ "client.perf.MixinBlockEntityLightCache", //? if >= 1.21.10 "client.perf.MixinEntityCullingBlockEntityBox", + //? if >= 1.21.10 + "client.perf.MixinEntityCullingPrefetch", + //? if >= 1.21.10 + "client.perf.MixinEntityCullingPrefetchChunk", "client.perf.MixinBlockEntityLightCacheChunks", "client.perf.MixinBlockEntityLightCacheCombiner", "client.perf.MixinBlockEntityLightCacheHolder", @@ -858,6 +868,7 @@ "client.perf.wwaypoints.MixinClientDimensionUtils", "client.perf.wwaypoints.MixinHideWaypointCycle", "client.perf.punchy.MixinPunchyBlacklistCache", + "client.perf.tiertagger.MixinTierTaggerModeCache", "client.perf.gammautils.MixinGammaOptionGate", "client.perf.lambdynlights.MixinDynamicLightOptimisticRead", "client.perf.lambdynlights.MixinLightSourceSetRemove", @@ -870,6 +881,7 @@ "client.perf.scalablelux.MixinClientLightBudget", "client.perf.scalablelux.MixinClientLightBudgetTick", "client.perf.scalablelux.MixinChunkLoadDirtyOnce", + "client.perf.sodiumextra.MixinExpandedCylindricalGate", "client.perf.scalablelux.MixinLightNibbleView", "client.perf.sodium.MixinClonedLightView", "client.perf.particle_core.MixinParticleCoreFinalizeCycle", diff --git a/src/test/kotlin/org/polyfrost/polydium/test/ChunkPrefetchCacheTest.kt b/src/test/kotlin/org/polyfrost/polydium/test/ChunkPrefetchCacheTest.kt new file mode 100644 index 0000000..12d58b7 --- /dev/null +++ b/src/test/kotlin/org/polyfrost/polydium/test/ChunkPrefetchCacheTest.kt @@ -0,0 +1,31 @@ +package org.polyfrost.polydium.test + +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import org.polyfrost.polydium.client.perf.ChunkPrefetchCache + +class ChunkPrefetchCacheTest { + private class Chunk : ChunkPrefetchCache { + var list: Array? = null + var generation = 0 + override fun `polydium$storedPrefetch`() = list + override fun `polydium$prefetchGeneration`() = generation + override fun `polydium$setPrefetch`(cullables: Array?, generation: Int) { + list = cullables + this.generation = generation + } + } + + @Test + fun `a list only survives while its chunk and generation are unchanged`() { + val chunk = Chunk() + val list = arrayOf("chest") + assertNull(chunk.`polydium$prefetch`(1)) + chunk.`polydium$setPrefetch`(list, 1) + assertSame(list, chunk.`polydium$prefetch`(1)) + assertNull(chunk.`polydium$prefetch`(2)) + chunk.`polydium$invalidatePrefetch`() + assertNull(chunk.`polydium$prefetch`(1)) + } +} diff --git a/src/test/kotlin/org/polyfrost/polydium/test/ExpandedCylindricalGateTest.kt b/src/test/kotlin/org/polyfrost/polydium/test/ExpandedCylindricalGateTest.kt new file mode 100644 index 0000000..4c82ff1 --- /dev/null +++ b/src/test/kotlin/org/polyfrost/polydium/test/ExpandedCylindricalGateTest.kt @@ -0,0 +1,112 @@ +package org.polyfrost.polydium.test + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import org.objectweb.asm.tree.AbstractInsnNode +import org.objectweb.asm.tree.ClassNode +import org.objectweb.asm.tree.FieldNode +import org.objectweb.asm.tree.InsnList +import org.objectweb.asm.tree.InsnNode +import org.objectweb.asm.tree.JumpInsnNode +import org.objectweb.asm.tree.LabelNode +import org.objectweb.asm.tree.MethodInsnNode +import org.objectweb.asm.tree.MethodNode +import org.objectweb.asm.tree.VarInsnNode +import org.objectweb.asm.tree.analysis.Analyzer +import org.objectweb.asm.tree.analysis.BasicVerifier +import org.polyfrost.polydium.mixin.PolydiumMixinPlugin + +// Models the bytecode Mixin 0.8.7's ModifyArgsInjector leaves in processQueue for Sodium Extra 0.9.4's +// sodiumExtra$allowExpandedCylindricalVerticalTraversal(Args) on both the Sodium 0.8 and 0.9 OcclusionCuller. +class ExpandedCylindricalGateTest { + private val owner = "net/caffeinemc/mods/sodium/client/render/chunk/occlusion/OcclusionCuller" + private val argsClass = "org/spongepowered/asm/synthetic/args/Args\$1" + private val handlerName = "handler\$zzz000\$sodium-extra\$sodiumExtra\$allowExpandedCylindricalVerticalTraversal" + private val handlerDesc = "(Lorg/spongepowered/asm/mixin/injection/invoke/arg/Args;)V" + + @Test + fun `static sodium 08 culler skips args packing unless the search distance is expanded`() { + val neighbors = "(Ljava/lang/Object;Ljava/lang/Object;II)V" + val method = processQueue(Opcodes.ACC_PRIVATE or Opcodes.ACC_STATIC, "(Ljava/lang/Object;Ljava/lang/Object;FLjava/lang/Object;)V", neighbors, static = true) { + add(VarInsnNode(Opcodes.ALOAD, 0)) + add(VarInsnNode(Opcodes.ALOAD, 3)) + add(InsnNode(Opcodes.ICONST_1)) + add(InsnNode(Opcodes.ICONST_2)) + } + val target = culler(method) + + PolydiumMixinPlugin.gateExpandedCylindricalTraversal(target, method) + + assertGated(target, method, listOf("isExpandedCylindricalCullDistance", "of", handlerName)) + } + + @Test + fun `instance sodium 09 culler skips args packing unless either search distance is expanded`() { + val neighbors = "(Ljava/lang/Object;Ljava/lang/Object;IIILjava/lang/Object;)V" + val method = processQueue(Opcodes.ACC_PRIVATE, "(Ljava/lang/Object;Ljava/lang/Object;)V", neighbors, static = false) { + add(VarInsnNode(Opcodes.ALOAD, 0)) + add(VarInsnNode(Opcodes.ALOAD, 1)) + add(VarInsnNode(Opcodes.ALOAD, 2)) + add(InsnNode(Opcodes.ICONST_1)) + add(InsnNode(Opcodes.ICONST_2)) + add(InsnNode(Opcodes.ICONST_3)) + add(VarInsnNode(Opcodes.ALOAD, 1)) + } + val target = culler(method).apply { + fields.add(FieldNode(Opcodes.ACC_PRIVATE, "searchDistanceRegular", "F", null, null)) + fields.add(FieldNode(Opcodes.ACC_PRIVATE, "searchDistanceLocal", "F", null, null)) + } + + PolydiumMixinPlugin.gateExpandedCylindricalTraversal(target, method) + + assertGated(target, method, listOf("isExpandedCylindricalCullDistance", "isExpandedCylindricalCullDistance", "of", handlerName)) + } + + private fun culler(method: MethodNode) = ClassNode().apply { + version = Opcodes.V21 + name = owner + superName = "java/lang/Object" + methods.add(method) + } + + private fun processQueue(access: Int, desc: String, neighbors: String, static: Boolean, pushArgs: InsnList.() -> Unit) = + MethodNode(access, "processQueue", desc, null, null).apply { + val args = Type.getArgumentTypes(neighbors) + instructions.add(InsnList().apply(pushArgs)) + instructions.add(MethodInsnNode(Opcodes.INVOKESTATIC, argsClass, "of", Type.getMethodDescriptor(Type.getObjectType(argsClass), *args), false)) + instructions.add(InsnNode(Opcodes.DUP)) + if (static) { + instructions.add(MethodInsnNode(Opcodes.INVOKESTATIC, owner, handlerName, handlerDesc, false)) + } else { + instructions.add(VarInsnNode(Opcodes.ALOAD, 0)) + instructions.add(InsnNode(Opcodes.SWAP)) + instructions.add(MethodInsnNode(Opcodes.INVOKESPECIAL, owner, handlerName, handlerDesc, false)) + } + args.forEachIndexed { i, type -> + val last = i == args.size - 1 + if (!last) instructions.add(InsnNode(Opcodes.DUP)) + instructions.add(MethodInsnNode(Opcodes.INVOKEVIRTUAL, argsClass, "\$$i", "()" + type.descriptor, false)) + if (!last) instructions.add(InsnNode(Opcodes.SWAP)) + } + instructions.add(MethodInsnNode(if (static) Opcodes.INVOKESTATIC else Opcodes.INVOKEVIRTUAL, owner, "visitNeighbors", neighbors, false)) + instructions.add(InsnNode(Opcodes.RETURN)) + maxStack = 16 + maxLocals = 8 + } + + private fun assertGated(target: ClassNode, method: MethodNode, callsBeforeHandler: List) { + val calls = method.instructions.filterIsInstance().map { it.name } + assertEquals(callsBeforeHandler, calls.take(callsBeforeHandler.size)) + + val neighbors = method.instructions.filterIsInstance().single { it.name == "visitNeighbors" } + val skip = method.instructions.filterIsInstance().last() + assertEquals(previousLabel(neighbors), skip.label) + assert(skip.opcode == Opcodes.IFEQ || skip.opcode == Opcodes.GOTO) + + Analyzer(BasicVerifier()).analyze(target.name, method) + } + + private fun previousLabel(insn: AbstractInsnNode): LabelNode? = insn.previous as? LabelNode +}