diff --git a/src/main/java/asm/patchify/annotation/Patch.java b/src/main/java/asm/patchify/annotation/Patch.java index acae94e..81e17f0 100644 --- a/src/main/java/asm/patchify/annotation/Patch.java +++ b/src/main/java/asm/patchify/annotation/Patch.java @@ -8,5 +8,13 @@ import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Patch { - Class value(); + Class value() default void.class; + + /** + * Alternative to {@link #value()} for targeting classes that are not available at compile time. + * When non-empty, takes precedence over {@code value()}. This is useful for targeting classes from + * optional mods (e.g. Embeddium/Sodium). + *

Use the fully qualified JVM class name (e.g. {@code "me.jellysquid.mods.sodium.client.render.chunk.compile.pipeline.BlockOcclusionCache"}).

+ */ + String className() default ""; } diff --git a/src/main/java/asm/patchify/loader/PatchAgent.java b/src/main/java/asm/patchify/loader/PatchAgent.java index c075a8c..82581f3 100644 --- a/src/main/java/asm/patchify/loader/PatchAgent.java +++ b/src/main/java/asm/patchify/loader/PatchAgent.java @@ -72,6 +72,29 @@ public final class PatchAgent { for (Class patch : PatchRegistry.getPatches()) { asm.patchify.annotation.Patch ann = patch.getAnnotation(asm.patchify.annotation.Patch.class); if (ann == null) continue; + if (!ann.className().isEmpty()) { + // className-based patches target optional mod classes. + // Check if the class is already loaded via Instrumentation. + boolean found = false; + for (Class loaded : inst.getAllLoadedClasses()) { + if (loaded.getName().equals(ann.className())) { + LOGGER.debug("Found already-loaded target {} for className-based patch {}", + ann.className(), patch.getName()); + if (inst.isModifiableClass(loaded)) { + retransform.add(loaded); + } else { + LOGGER.warn("Cannot retransform unmodifiable target {}", ann.className()); + } + found = true; + break; + } + } + if (!found) { + LOGGER.debug("Target {} not yet loaded — transformer will catch it at class-load time", + ann.className()); + } + continue; + } Class target; try { target = ann.value(); diff --git a/src/main/java/asm/patchify/loader/PatchClassFileTransformer.java b/src/main/java/asm/patchify/loader/PatchClassFileTransformer.java index 62f5f78..7ba0aff 100644 --- a/src/main/java/asm/patchify/loader/PatchClassFileTransformer.java +++ b/src/main/java/asm/patchify/loader/PatchClassFileTransformer.java @@ -42,7 +42,12 @@ public final class PatchClassFileTransformer implements ClassFileTransformer { for (Class patchClass : PatchRegistry.getPatches()) { Patch patch = patchClass.getAnnotation(Patch.class); if (patch == null) continue; - String internalName = patch.value().getName().replace('.', '/'); + String internalName; + if (!patch.className().isEmpty()) { + internalName = patch.className().replace('.', '/'); + } else { + internalName = patch.value().getName().replace('.', '/'); + } patchesByTarget.computeIfAbsent(internalName, k -> new ArrayList<>()).add(patchClass); } } diff --git a/src/main/java/asm/patchify/loader/PatchTransformer.java b/src/main/java/asm/patchify/loader/PatchTransformer.java index 80b4e63..8d052b3 100644 --- a/src/main/java/asm/patchify/loader/PatchTransformer.java +++ b/src/main/java/asm/patchify/loader/PatchTransformer.java @@ -72,20 +72,42 @@ public final class PatchTransformer { if (patchAnnotation == null) { throw new IllegalArgumentException(patchClass.getName() + " is not @Patch"); } - String patchTargetOwner = Type.getInternalName(patchAnnotation.value()); + String patchTargetOwner; + if (!patchAnnotation.className().isEmpty()) { + patchTargetOwner = patchAnnotation.className().replace('.', '/'); + } else { + patchTargetOwner = Type.getInternalName(patchAnnotation.value()); + } Map> handlersByTarget = new HashMap<>(); + // Collect handlers whose desc is empty (name-only wildcards) separately + List nameOnlyHandlers = new ArrayList<>(); for (Method handler : patchClass.getDeclaredMethods()) { - collectHandler(patchClass, patchTargetOwner, handler, handlersByTarget); + collectHandler(patchClass, patchTargetOwner, handler, handlersByTarget, nameOnlyHandlers); } LOGGER.info("Loading patch {} -> {} ({} handler(s))", patchClass.getName(), target.name, - handlersByTarget.values().stream().mapToInt(List::size).sum()); + handlersByTarget.values().stream().mapToInt(List::size).sum() + nameOnlyHandlers.size()); Set matched = new HashSet<>(); + Set nameOnlyMatched = new HashSet<>(); for (MethodNode method : target.methods) { MethodKey key = new MethodKey(method.name, method.desc); List handlers = handlersByTarget.get(key); + if (handlers == null && !nameOnlyHandlers.isEmpty()) { + // Try name-only match for handlers registered with empty desc + for (Method candidate : nameOnlyHandlers) { + Inject inject = candidate.getAnnotation(Inject.class); + Overwrite overwrite = candidate.getAnnotation(Overwrite.class); + String targetName = inject != null ? inject.method() + : overwrite != null ? overwrite.method() : null; + if (targetName != null && targetName.equals(method.name)) { + if (handlers == null) handlers = new ArrayList<>(); + handlers.add(candidate); + nameOnlyMatched.add(method.name); + } + } + } if (handlers == null) continue; matched.add(key); for (Method handler : handlers) { @@ -121,11 +143,34 @@ public final class PatchTransformer { target.name, entry.getKey().name(), entry.getKey().desc(), target.name); } } + // Warn about unmatched name-only handlers + for (Method handler : nameOnlyHandlers) { + if (!nameOnlyMatched.contains(getHandlerTargetMethodName(handler))) { + LOGGER.warn("Patch handler {}#{} is name-only (empty desc) but no method named \"{}\" exists on {} — handler will not run.", + handler.getDeclaringClass().getName(), handler.getName(), + getHandlerTargetMethodName(handler), target.name); + } + } + } + + private static String getHandlerTargetMethodName(Method handler) { + Inject inject = handler.getAnnotation(Inject.class); + if (inject != null) return inject.method(); + Overwrite overwrite = handler.getAnnotation(Overwrite.class); + if (overwrite != null) return overwrite.method(); + Transform transform = handler.getAnnotation(Transform.class); + if (transform != null) return transform.method(); + WrapInvoke wrap = handler.getAnnotation(WrapInvoke.class); + if (wrap != null) return wrap.method(); + ModifyLocals modify = handler.getAnnotation(ModifyLocals.class); + if (modify != null) return modify.method(); + return "?"; } private static void collectHandler(Class patchClass, String patchTargetOwner, Method handler, - Map> handlersByTarget) { + Map> handlersByTarget, + List nameOnlyHandlers) { if (!(handler.isAnnotationPresent(Inject.class) || handler.isAnnotationPresent(Overwrite.class) || handler.isAnnotationPresent(Transform.class) @@ -176,7 +221,13 @@ public final class PatchTransformer { // production Forge environment the live class only has SRG names, so // remap before matching against ClassNode.methods. name = Bootstrap.remapMethod(patchTargetOwner, name, desc); - handlersByTarget.computeIfAbsent(new MethodKey(name, desc), k -> new ArrayList<>()).add(handler); + if (desc.isEmpty()) { + // Empty desc = match by method name only (wildcard for mod classes + // whose exact descriptor may vary between Yarn and Mojmap mappings). + nameOnlyHandlers.add(handler); + } else { + handlersByTarget.computeIfAbsent(new MethodKey(name, desc), k -> new ArrayList<>()).add(handler); + } } private static void validateInjectSignature(Class patchClass, Method handler, Inject inject) { @@ -700,7 +751,9 @@ public final class PatchTransformer { private static String targetClassName(Method handler) { Patch patch = handler.getDeclaringClass().getAnnotation(Patch.class); - return patch == null ? "?" : Type.getInternalName(patch.value()); + if (patch == null) return "?"; + if (!patch.className().isEmpty()) return patch.className().replace('.', '/'); + return Type.getInternalName(patch.value()); } private static List collectInjectionPoints(InsnList insns, Slice slice, diff --git a/src/main/java/shit/zen/ZenClient.java b/src/main/java/shit/zen/ZenClient.java index 3418d20..4659150 100644 --- a/src/main/java/shit/zen/ZenClient.java +++ b/src/main/java/shit/zen/ZenClient.java @@ -12,6 +12,8 @@ import lombok.Getter; import lombok.Setter; import net.minecraft.client.Minecraft; import net.minecraftforge.fml.common.Mod; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import shit.zen.event.EventBus; import shit.zen.event.EventTarget; import shit.zen.event.impl.TickEvent; @@ -22,6 +24,7 @@ import shit.zen.manager.HudManager; import shit.zen.manager.LagManager; import shit.zen.manager.ModuleManager; import shit.zen.manager.TargetManager; +import shit.zen.patch.BlockOcclusionCachePatch; import shit.zen.patch.BlockPatch; import shit.zen.patch.ChatScreenPatch; import shit.zen.patch.ClientLevelPatch; @@ -209,6 +212,13 @@ public class ZenClient extends ClientBase { PatchRegistry.register(ItemPatch.class); PatchRegistry.register(PlayerTabOverlayPatch.class); PatchRegistry.register(FriendlyByteBufPatch.class); + + // Compatibility patch for Embeddium/Sodium's BlockOcclusionCache. + // Always registered so the transformer can catch the class when it + // first loads. We must NOT use Class.forName() here — that would + // load the class before our transformer is installed, preventing + // the patch from ever being applied. + PatchRegistry.register(BlockOcclusionCachePatch.class); } public static Minecraft getMcInstance() { diff --git a/src/main/java/shit/zen/patch/BlockOcclusionCachePatch.java b/src/main/java/shit/zen/patch/BlockOcclusionCachePatch.java new file mode 100644 index 0000000..330909e --- /dev/null +++ b/src/main/java/shit/zen/patch/BlockOcclusionCachePatch.java @@ -0,0 +1,72 @@ +package shit.zen.patch; + +import asm.patchify.annotation.At; +import asm.patchify.annotation.Inject; +import asm.patchify.annotation.Patch; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import shit.zen.modules.impl.render.XRay; + +/** + * Compatibility patch for Embeddium/Sodium's {@code BlockOcclusionCache}. + * + *

Sodium/Embeddium replaces vanilla's chunk mesh building with its own pipeline. The + * vanilla {@link Block#shouldRenderFace()} method — which our {@link BlockPatch} hooks + * — is never called by Embeddium's {@code BlockRenderer}. Instead, face visibility is + * decided by {@code BlockOcclusionCache.shouldDrawSide()}, which has its own entirely + * independent occlusion logic.

+ * + *

This patch injects into {@code BlockOcclusionCache.shouldDrawSide()} so that when + * XRay is enabled the result is forced to match {@link XRay#isXrayVisible(Block)}, + * exactly like our vanilla patch.

+ * + *

The target class is referenced by {@link Patch#className()} rather than by + * {@link Patch#value()} because Embeddium is an optional mod — the class is not + * available at compile time. Registration in + * {@link shit.zen.ZenClient#registerPatches()} is guarded by a + * {@link Class#forName(String)} check so the patch is only loaded when Embeddium is + * present. The method descriptor is left empty (name-only match) because the parameter + * types in Embeddium's bytecode may differ between Yarn and Mojmap mappings depending + * on the Embeddium build and compatibility layer.

+ */ +@Patch(className = "me.jellysquid.mods.sodium.client.render.chunk.compile.pipeline.BlockOcclusionCache") +public class BlockOcclusionCachePatch { + + /** + * Injected at HEAD of {@code shouldDrawSide}. When XRay is enabled we cancel the + * original method and force the result: target blocks return {@code true} (all + * faces visible = rendered through walls) and non-target blocks return + * {@code false} (no faces visible = completely transparent). + * + *

All parameters are declared as {@link Object} rather than their actual types + * because Embeddium may be compiled with either Yarn or Mojmap mappings — using + * {@code Object} avoids a {@code VerifyError} when the handler's descriptor + * doesn't match the runtime descriptor exactly. The only param we actually read + * is {@code selfState}, which is cast to Mojmap {@link BlockState} (always + * correct at runtime since the loaded Minecraft classes are Mojmap-mapped in a + * Forge environment).

+ * + * @param self the {@code BlockOcclusionCache} instance (unused) + * @param selfState the {@code BlockState} of the block being rendered + * @param view the {@code BlockView/BlockGetter} (unused) + * @param pos the {@code BlockPos} (unused) + * @param direction the facing {@code Direction} (unused) + * @param ci callback info — cancelled with redirected result + */ + @Inject( + method = "shouldDrawSide", + desc = "", + at = @At(At.Type.HEAD) + ) + public static void onShouldDrawSide(Object self, Object selfState, Object view, + Object pos, Object direction, CallbackInfo ci) { + XRay xray = XRay.INSTANCE; + if (xray == null || !xray.isEnabled()) { + return; // XRay off -> keep original Embeddium occlusion logic + } + // Cast to Mojmap BlockState — always correct at runtime on Forge + boolean visible = xray.isXrayVisible(((BlockState) selfState).getBlock()); + ci.result = visible ? Boolean.TRUE : Boolean.FALSE; + ci.cancel(); + } +}