From f8e875486d7f3e48ba7d69a456d0756f9378d3af Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Thu, 11 Sep 2025 13:59:25 +0200
Subject: [PATCH 01/14] Enhance AsmWeaver with additional validations and error
handling:
- Skip weaving for classes/methods with no injects or redirects.
- Handle write failures with optional safe mode fallback.
- Introduce `pathResolve` and `pathWeave` for detailed diagnostics.
- Skip abstract and native method transformations.
- Add `@SuppressWarnings` to avoid constant condition warnings.
---
.../mixins/bytecode/weaver/asm/AsmWeaver.java | 76 ++++++++++++++++---
1 file changed, 65 insertions(+), 11 deletions(-)
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
index bfb702c..402db34 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
@@ -32,6 +32,8 @@
import java.util.Objects;
import java.util.Optional;
+import static org.objectweb.asm.Opcodes.ACC_ABSTRACT;
+import static org.objectweb.asm.Opcodes.ACC_NATIVE;
import static org.objectweb.asm.Opcodes.ASM9;
/**
@@ -132,6 +134,12 @@ public WeaveResult weave(@NotNull final WeaveRequest request,
final String internalName = it.getKey();
final ClassWork cw = it.getValue();
+ if (cw.getInjects().isEmpty() && cw.getRedirects().isEmpty()) {
+ entries.add(new WeaveResult.Entry(it.getKey(), WeaveResult.Outcome.SKIPPED));
+ skipped++;
+ continue;
+ }
+
final byte[] original;
try {
original = request.source().getClassBytes(internalName);
@@ -155,9 +163,18 @@ public WeaveResult weave(@NotNull final WeaveRequest request,
final boolean changed = transformedBytes != null;
if (changed) {
- request.sink().accept(internalName, transformedBytes);
- entries.add(new WeaveResult.Entry(internalName, WeaveResult.Outcome.TRANSFORMED));
- transformed++;
+ try {
+ request.sink().accept(internalName, transformedBytes);
+ entries.add(new WeaveResult.Entry(internalName, WeaveResult.Outcome.TRANSFORMED));
+ transformed++;
+ } catch (final IOException ioe) {
+ problems.error("weave/" + internalName, "Failed writing class: " + ioe.getMessage());
+ if (!safe) {
+ throw ioe;
+ }
+ entries.add(new WeaveResult.Entry(internalName, WeaveResult.Outcome.FAILED));
+ failed++;
+ }
} else {
entries.add(new WeaveResult.Entry(internalName, WeaveResult.Outcome.SKIPPED));
skipped++;
@@ -186,6 +203,7 @@ public WeaveResult weave(@NotNull final WeaveRequest request,
* @return a map from internal class name to {@link ClassWork}, never {@code null}
*/
@NotNull
+ @SuppressWarnings("ConstantConditions")
private Map buildWork(@NotNull final WeavePlan plan,
@NotNull final ConfigProblems problems) {
final Map map = new LinkedHashMap<>();
@@ -195,19 +213,23 @@ private Map buildWork(@NotNull final WeavePlan plan,
final ClassWork cw = map.computeIfAbsent(target, k -> new ClassWork(target));
for (final PlannedEntry pe : mixin.getEntries()) {
final Optional rhOpt = this.resolver.resolve(
- mixin, pe, problems, "resolve/" + target + "/" + mixin.getClassName() + ":" + pe.getId());
+ mixin, pe, problems, "resolve/" + target + "/" + mixin.getClassName() + ":" + pe.getId()
+ );
+ final String ctx = pathResolve(target, mixin, pe);
if (rhOpt.isEmpty()) {
- problems.error("resolve/" + target, "No hook resolved for " + mixin.getClassName() + " id=" + pe.getId());
+ final String msg = "No hook resolved for " + mixin.getClassName() + " id=" + pe.getId();
+ if (pe.isOptional()) {
+ problems.warn(ctx, msg + " (optional; skipping)");
+ } else {
+ problems.error(ctx, msg);
+ }
continue;
}
final ResolvedHook rh = rhOpt.get();
switch (pe.getKind()) {
case INJECT -> {
- if (!"()V".equals(rh.desc())) {
- problems.error("resolve/" + target, "INJECT hook must be ()V in MVP: " + rh.owner() + "." + rh.name() + rh.desc());
- continue;
- }
- cw.getInjects().computeIfAbsent(this.sigOf(pe.getMethod()), k -> new ArrayList<>())
+ cw.getInjects()
+ .computeIfAbsent(this.sigOf(pe.getMethod()), k -> new ArrayList<>())
.add(new InjectionSpec(pe.getAt(), rh, pe.isOptional(), pe.isRemap(), pe.getId(), mixin.getPriority()));
}
case REDIRECT ->
@@ -260,6 +282,7 @@ private byte[] weaveClass(@NotNull final byte[] original,
@NotNull final ConfigProblems problems) {
final VerifyFrames verify = request.runtime().getVerifyFrames();
final boolean compute = verify.isAtLeast(VerifyFrames.BASIC);
+ final int acceptFlags = compute ? ClassReader.EXPAND_FRAMES : 0;
final ClassReader cr = new ClassReader(original);
final ClassWriter cw = compute
@@ -277,6 +300,11 @@ public MethodVisitor visitMethod(final int access,
final String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
+ // fix: skip abstract/native methods
+ if ((access & (ACC_ABSTRACT | ACC_NATIVE)) != 0) {
+ return mv;
+ }
+
final String sig = name + descriptor;
final List inj = work.getInjects().getOrDefault(sig, List.of());
@@ -333,10 +361,36 @@ public MethodVisitor visitMethod(final int access,
}
};
- cr.accept(cv, compute ? ClassReader.SKIP_FRAMES : 0);
+ cr.accept(cv, acceptFlags);
return changed.isSet() ? cw.toByteArray() : null;
}
+ /**
+ * Constructs a unique problem context path for a specific target class and planned
+ * mixin entry.
+ *
+ * @param target target class internal JVM name (slash-separated); never {@code null}
+ * @param mixin the planned mixin; never {@code null}
+ * @param pe the planned entry; never {@code null}
+ * @return a context path string for diagnostics; never {@code null}
+ */
+ @NotNull
+ private static String pathResolve(@NotNull final String target, @NotNull final PlannedMixin mixin, @NotNull final PlannedEntry pe) {
+ return "resolve/" + target + "/" + mixin.getClassName() + ":" + pe.getId();
+ }
+
+ /**
+ * Constructs a unique problem context path for a specific target class and method signature.
+ *
+ * @param internalName internal JVM class name (slash-separated); never {@code null}
+ * @param sig method signature (name + descriptor); never {@code null}
+ * @return a context path string for diagnostics; never {@code null}
+ */
+ @NotNull
+ private static String pathWeave(@NotNull final String internalName, @NotNull final String sig) {
+ return "weave/" + internalName + "/" + sig;
+ }
+
/**
* Derives the per-method signature key used to group specs within a class.
*
From b3d229986dc663739c5cffe4c5195b984cdfc672 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Thu, 11 Sep 2025 14:46:59 +0200
Subject: [PATCH 02/14] Extend `InjectHeadAdapter` and `InjectTailAdapter` to
support flexible hook parameter shapes:
- Add support for hook descriptor shapes: `()V`, `(OWNER;)V`, `(args)V`, `(OWNER;args)V`.
- Introduce type validation and operand marshaling logic for descriptor compatibility.
- Adjust weaving logic in `AsmHookResolver`, `InjectHeadAdapter`, and `InjectTailAdapter` to support enhanced validation and descriptor matching.
- Update `AsmWeaver` to pass owner, access, and descriptor into adapters.
---
.../bytecode/weaver/asm/AsmHookResolver.java | 25 +--
.../mixins/bytecode/weaver/asm/AsmWeaver.java | 2 +
.../weaver/asm/adapter/InjectHeadAdapter.java | 170 +++++++++++++++++-
.../weaver/asm/adapter/InjectTailAdapter.java | 164 ++++++++++++++++-
4 files changed, 332 insertions(+), 29 deletions(-)
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmHookResolver.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmHookResolver.java
index f1a1752..ba500a1 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmHookResolver.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmHookResolver.java
@@ -18,25 +18,18 @@
import java.util.Optional;
/**
- * ASM-based {@link HookResolver} implementation that loads a mixin class, scans its bytecode
- * for {@code @Inject}/{@code @Redirect} annotated static methods, and resolves exactly one
- * matching hook for a given {@link PlannedEntry}.
+ * ASM-based {@link HookResolver} that loads a mixin class, scans its bytecode
+ * for {@code @Inject}/{@code @Redirect} annotated static methods, and resolves
+ * exactly one matching hook for a given {@link PlannedEntry}.
*
* Resolution workflow:
*
* - Load class bytes via {@link ClassSource}.
* - Scan for static methods carrying the required annotation (via {@link HookScanner}).
* - Filter by the planned {@code id} selection policy.
- * - Validate MVP constraints for the resulting candidate.
+ * - Perform minimal kind-specific validation where applicable.
*
*
- * MVP validation:
- *
- * - INJECT: descriptor must be {@code ()V}.
- * - REDIRECT: descriptor must equal the original call descriptor for static calls;
- * for instance calls, the receiver type is prepended as first argument.
- *
- *
* Example
*
* ClassSource source = ...;
@@ -116,14 +109,8 @@ public Optional resolve(
final CandidateHook mi = matched.get(0);
- // (3) Validate MVP constraints
- if (entry.getKind() == PlannedEntry.Kind.INJECT) {
- if (!"()V".equals(mi.desc)) {
- problems.error(path, "INJECT hook must be ()V but was " + mi.desc +
- " at " + internal + "." + mi.name + mi.desc);
- return Optional.empty();
- }
- } else {
+ // (3) Minimal validation for redirects (inject hooks are validated during weaving where full context is available)
+ if (entry.getKind() != PlannedEntry.Kind.INJECT) {
final String expected = expectedRedirectHookDesc(
entry.getInvokeKind(), entry.getCallOwner(), entry.getCallDesc()
);
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
index 402db34..56efc55 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
@@ -344,6 +344,7 @@ public MethodVisitor visitMethod(final int access,
for (final InjectionSpec t : tailSorted) {
mv = new InjectTailAdapter(
this.api, mv,
+ internalName, access, descriptor,
t.hook(), t.optional(), t.id(),
changed::getAndSet,
problems, internalName, sig
@@ -352,6 +353,7 @@ public MethodVisitor visitMethod(final int access,
for (final InjectionSpec h : headSorted) {
mv = new InjectHeadAdapter(
this.api, mv,
+ internalName, access, descriptor,
h.hook(), h.optional(), h.id(),
changed::getAndSet,
problems, internalName, sig
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
index afd4e3a..fd7efd6 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
@@ -3,25 +3,30 @@
import de.splatgames.aether.mixins.bytecode.weaver.hook.ResolvedHook;
import de.splatgames.aether.mixins.core.config.problems.ConfigProblems;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
import static org.objectweb.asm.Opcodes.INVOKESTATIC;
/**
- * Method visitor that injects a single static {@code ()V} hook call at the very beginning
+ * Method visitor that injects a single static hook call at the very beginning
* of the visited method (i.e., at the method prologue / {@linkplain #visitCode() code entry}).
*
* Behavior:
*
* - On {@link #visitCode()}, emits an {@code INVOKESTATIC} to the configured {@link #hook}.
+ * - Supports four hook descriptor shapes:
+ * {@code ()V}, {@code (OWNER;)V}, {@code (args)V}, {@code (OWNER;args)V}.
* - Marks the enclosing weaving operation as changed via {@link #markChanged}.
* - If no code is ever visited (edge cases), a non-optional injection fails fast in {@link #visitEnd()}.
*
*
* Contract:
*
- * - {@link #hook} must refer to a static method with descriptor {@code ()V}.
- * - This adapter does not perform descriptor verification; callers should validate prior to use.
+ * - {@link #hook} must refer to a static method. Valid shapes: {@code ()V}, {@code (OWNER;)V}, {@code (args)V}, {@code (OWNER;args)V}.
+ * - Descriptor compatibility is verified against the target method at weave-time.
*
*
* Thread-safety: instances are not thread-safe and must be used by a single ASM visitation thread.
@@ -31,6 +36,8 @@
*/
public final class InjectHeadAdapter extends MethodVisitor {
+ private enum HookShape {NONE, THIS, ARGS, THIS_ARGS}
+
/**
* The resolved hook (owner/name/desc) to invoke at method entry.
*/
@@ -68,6 +75,23 @@ public final class InjectHeadAdapter extends MethodVisitor {
@SuppressWarnings("unused, FieldCanBeLocal")
private final String ctx;
+ /**
+ * Target method context (owner/access/descriptor) used to validate and marshal operands.
+ */
+ @NotNull
+ private final String ownerInternal;
+
+ /**
+ * Target method context (owner/access/descriptor) used to validate and marshal operands.
+ */
+ private final int targetAccess;
+
+ /**
+ * Target method context (owner/access/descriptor) used to validate and marshal operands.
+ */
+ @NotNull
+ private final String targetDesc;
+
/**
* Tracks whether the injection has been applied at least once.
*/
@@ -78,6 +102,9 @@ public final class InjectHeadAdapter extends MethodVisitor {
*
* @param api ASM API level to use
* @param mv downstream method visitor to delegate to; must not be {@code null}
+ * @param ownerInternal internal JVM class name of the target method (e.g., {@code com/example/Foo}); must not be {@code null}
+ * @param targetAccess access flags of the target method (e.g., {@code ACC_PUBLIC | ACC_STATIC})
+ * @param targetDesc method descriptor of the target method (e.g., {@code (I)V}); must not be {@code null}
* @param hook resolved hook to invoke; must not be {@code null} and must be {@code ()V}
* @param optional whether to tolerate a missing injection (no code visited) without failing
* @param id developer-defined identifier used in diagnostics; must not be {@code null}
@@ -88,6 +115,9 @@ public final class InjectHeadAdapter extends MethodVisitor {
*/
public InjectHeadAdapter(final int api,
@NotNull final MethodVisitor mv,
+ @NotNull final String ownerInternal,
+ final int targetAccess,
+ @NotNull final String targetDesc,
@NotNull final ResolvedHook hook,
final boolean optional,
@NotNull final String id,
@@ -96,6 +126,9 @@ public InjectHeadAdapter(final int api,
@NotNull final String cls,
@NotNull final String sig) {
super(api, mv);
+ this.ownerInternal = ownerInternal;
+ this.targetAccess = targetAccess;
+ this.targetDesc = targetDesc;
this.hook = hook;
this.optional = optional;
this.id = id;
@@ -105,14 +138,139 @@ public InjectHeadAdapter(final int api,
}
/**
- * Emits the hook call at the beginning of the method body.
+ * Determines whether the target method is an instance method (i.e., not static).
*
- * Specifically, this calls {@code INVOKESTATIC hook.owner()/hook.name() hook.desc()} and
- * then marks the enclosing weaving operation as changed.
+ * @param access access flags of the target method
+ * @return {@code true} if the target method is an instance method, {@code false} if it is static
+ */
+ private static boolean isInstance(final int access) {
+ return (access & Opcodes.ACC_STATIC) == 0;
+ }
+
+ /**
+ * Parses the argument types from a method descriptor.
+ *
+ * @param desc method descriptor (e.g., {@code (I)V}); must not be {@code null}
+ * @return array of argument types; never {@code null}, may be empty
+ */
+ @NotNull
+ private static Type[] argTypes(@NotNull final String desc) {
+ return Type.getArgumentTypes(desc);
+ }
+
+ /**
+ * Parses the owner type from an internal class name.
+ *
+ * @param internal internal JVM class name (e.g., {@code com/example/Foo}); must not be {@code null}
+ * @return corresponding object type; never {@code null}
+ */
+ @NotNull
+ private static Type ownerType(final String internal) {
+ return Type.getObjectType(internal);
+ }
+
+ /**
+ * Determines the size of a local variable slot for the given type.
+ *
+ * @param t type to check; must not be {@code null}
+ * @return size of the local variable slot (1 or 2)
+ */
+ private static int localSize(@NotNull final Type t) {
+ return (t == Type.LONG_TYPE || t == Type.DOUBLE_TYPE) ? 2 : 1;
+ }
+
+ /**
+ * Emits the appropriate {@code xLOAD} instruction to load a local variable of the given type.
+ *
+ * @param mv method visitor to emit to; must not be {@code null}
+ * @param t type of the local variable; must not be {@code null}
+ * @param idx index of the local variable to load
+ * @throws IllegalArgumentException if the type is unsupported
+ */
+ private static void loadLocal(@NotNull final MethodVisitor mv, @NotNull final Type t, final int idx) {
+ switch (t.getSort()) {
+ case Type.BOOLEAN, Type.BYTE, Type.SHORT, Type.CHAR, Type.INT -> mv.visitVarInsn(Opcodes.ILOAD, idx);
+ case Type.FLOAT -> mv.visitVarInsn(Opcodes.FLOAD, idx);
+ case Type.LONG -> mv.visitVarInsn(Opcodes.LLOAD, idx);
+ case Type.DOUBLE -> mv.visitVarInsn(Opcodes.DLOAD, idx);
+ case Type.ARRAY, Type.OBJECT -> mv.visitVarInsn(Opcodes.ALOAD, idx);
+ default -> throw new IllegalArgumentException("Unsupported type: " + t);
+ }
+ }
+
+ /**
+ * Matches the hook descriptor against the target method descriptor and owner type.
+ *
+ * @param instance whether the target method is an instance method (i.e., not static)
+ * @param targetDesc method descriptor of the target method (e.g., {@code (I)V}); must not be {@code null}
+ * @param ownerInternal internal JVM class name of the target method (e.g., {@code com/example/Foo}); must not be {@code null}
+ * @param hookDesc method descriptor of the hook method (e.g., {@code (Lcom/example/Foo;I)V}); must not be {@code null}
+ * @return matched hook shape, or {@code null} if the hook descriptor is incompatible with the target method
+ */
+ @Nullable
+ private static HookShape matchShape(final boolean instance, @NotNull final String targetDesc,
+ @NotNull final String ownerInternal, @NotNull final String hookDesc) {
+ final Type[] tArgs = argTypes(targetDesc);
+ final Type[] hArgs = argTypes(hookDesc);
+ final Type hRet = Type.getReturnType(hookDesc);
+ if (!Type.VOID_TYPE.equals(hRet)) {
+ return null;
+ }
+ if (hArgs.length == 0) {
+ return HookShape.NONE;
+ }
+ final Type ownerT = ownerType(ownerInternal);
+ if (hArgs.length == 1 && hArgs[0].equals(ownerT)) {
+ return instance ? HookShape.THIS : null;
+ }
+ if (hArgs.length == tArgs.length) {
+ for (int i = 0; i < hArgs.length; i++)
+ if (!hArgs[i].equals(tArgs[i])) {
+ return null;
+ }
+ return HookShape.ARGS;
+ }
+ if (hArgs.length == tArgs.length + 1 && hArgs[0].equals(ownerT)) {
+ if (!instance) {
+ return null;
+ }
+ for (int i = 0; i < tArgs.length; i++)
+ if (!hArgs[i + 1].equals(tArgs[i])) {
+ return null;
+ }
+ return HookShape.THIS_ARGS;
+ }
+ return null;
+ }
+
+ /**
+ * Injects the hook call at method entry.
+ *
+ * If the hook descriptor is incompatible with the target method, a non-optional injection
+ * fails fast with an {@link IllegalStateException}.
*/
@Override
public void visitCode() {
super.visitCode();
+ final boolean instance = isInstance(this.targetAccess);
+ final HookShape shape = matchShape(instance, this.targetDesc, this.ownerInternal, this.hook.desc());
+ if (shape == null) {
+ if (!this.optional) {
+ throw new IllegalStateException("HEAD inject: incompatible hook signature for id=" + this.id +
+ " hook=" + hook.owner() + "." + hook.name() + hook.desc());
+ }
+ return;
+ }
+ int local = instance ? 1 : 0;
+ if (shape == HookShape.THIS || shape == HookShape.THIS_ARGS) {
+ super.visitVarInsn(Opcodes.ALOAD, 0);
+ }
+ if (shape == HookShape.ARGS || shape == HookShape.THIS_ARGS) {
+ for (final Type t : argTypes(this.targetDesc)) {
+ loadLocal(this.mv, t, local);
+ local += localSize(t);
+ }
+ }
super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
this.markChanged.run();
this.applied = true;
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
index 8d638e2..4d72a9e 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
@@ -3,7 +3,10 @@
import de.splatgames.aether.mixins.bytecode.weaver.hook.ResolvedHook;
import de.splatgames.aether.mixins.core.config.problems.ConfigProblems;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
import static org.objectweb.asm.Opcodes.ARETURN;
import static org.objectweb.asm.Opcodes.DRETURN;
@@ -14,12 +17,14 @@
import static org.objectweb.asm.Opcodes.RETURN;
/**
- * Method visitor that injects a single static {@code ()V} hook call immediately before
+ * Method visitor that injects a single static hook call immediately before
* every return instruction of the visited method (TAIL injection).
*
* Behavior:
*
* - On each return opcode, emits an {@code INVOKESTATIC} to the configured {@link #hook}.
+ * - Supports four hook descriptor shapes:
+ * {@code ()V}, {@code (OWNER;)V}, {@code (args)V}, {@code (OWNER;args)V}.
* - Marks the enclosing weaving operation as changed via {@link #markChanged}.
* - If no return is ever visited, a non-optional injection fails fast in {@link #visitEnd()}.
*
@@ -39,6 +44,8 @@
*/
public final class InjectTailAdapter extends MethodVisitor {
+ private enum HookShape {NONE, THIS, ARGS, THIS_ARGS}
+
/**
* The resolved hook (owner/name/desc) to invoke before each return.
*/
@@ -76,6 +83,23 @@ public final class InjectTailAdapter extends MethodVisitor {
@SuppressWarnings("unused, FieldCanBeLocal")
private final String ctx;
+ /**
+ * Target method context (owner/access/descriptor) used to validate and marshal operands.
+ */
+ @NotNull
+ private final String ownerInternal;
+
+ /**
+ * Access flags of the target method (e.g., {@code ACC_PUBLIC | ACC_STATIC}).
+ */
+ private final int targetAccess;
+
+ /**
+ * Method descriptor of the target method (e.g., {@code (I)V}).
+ */
+ @NotNull
+ private final String targetDesc;
+
/**
* Tracks whether the injection has been applied at least once.
*/
@@ -86,6 +110,9 @@ public final class InjectTailAdapter extends MethodVisitor {
*
* @param api ASM API level to use
* @param mv downstream method visitor to delegate to; must not be {@code null}
+ * @param ownerInternal internal JVM class name of the target method (e.g., {@code com/example/Foo}); must not be {@code null}
+ * @param targetAccess access flags of the target method (e.g., {@code ACC_PUBLIC | ACC_STATIC})
+ * @param targetDesc method descriptor of the target method (e.g., {@code (I)V}); must not be {@code null}
* @param hook resolved hook to invoke; must not be {@code null} and must be {@code ()V}
* @param optional whether to tolerate the absence of return opcodes without failing
* @param id developer-defined identifier used in diagnostics; must not be {@code null}
@@ -96,6 +123,9 @@ public final class InjectTailAdapter extends MethodVisitor {
*/
public InjectTailAdapter(final int api,
@NotNull final MethodVisitor mv,
+ @NotNull final String ownerInternal,
+ final int targetAccess,
+ @NotNull final String targetDesc,
@NotNull final ResolvedHook hook,
final boolean optional,
@NotNull final String id,
@@ -104,6 +134,9 @@ public InjectTailAdapter(final int api,
@NotNull final String cls,
@NotNull final String sig) {
super(api, mv);
+ this.ownerInternal = ownerInternal;
+ this.targetAccess = targetAccess;
+ this.targetDesc = targetDesc;
this.hook = hook;
this.optional = optional;
this.id = id;
@@ -124,6 +157,112 @@ private static boolean isReturn(final int opcode) {
|| opcode == LRETURN || opcode == FRETURN || opcode == DRETURN;
}
+ /**
+ * Determines whether the method with the given access flags is an instance method.
+ *
+ * @param access the access flags to check
+ * @return {@code true} if the method is an instance method (i.e., not {@code static}); otherwise {@code false}
+ */
+ private static boolean isInstance(final int access) {
+ return (access & Opcodes.ACC_STATIC) == 0;
+ }
+
+ /**
+ * Extracts the argument types from the given method descriptor.
+ *
+ * @param desc the method descriptor to analyze; must not be {@code null}
+ * @return an array of {@link Type} representing the argument types in order
+ */
+ @NotNull
+ private static Type[] argTypes(@NotNull final String desc) {
+ return Type.getArgumentTypes(desc);
+ }
+
+ /**
+ * Constructs an object type from the given internal class name.
+ *
+ * @param internal the internal JVM class name (e.g., {@code com/example/Foo}); must not be {@code null}
+ * @return a {@link Type} representing the object type
+ */
+ @NotNull
+ private static Type ownerType(@NotNull final String internal) {
+ return Type.getObjectType(internal);
+ }
+
+ /**
+ * Determines the size of a local variable slot for the given type.
+ *
+ * @param t the type to analyze; must not be {@code null}
+ * @return {@code 2} if {@code t} is {@code long} or {@code double}; otherwise {@code 1}
+ */
+ private static int localSize(@NotNull final Type t) {
+ return (t == Type.LONG_TYPE || t == Type.DOUBLE_TYPE) ? 2 : 1;
+ }
+
+ /**
+ * Emits a load instruction for the given type from the specified local variable index.
+ *
+ * @param mv the method visitor to emit to; must not be {@code null}
+ * @param t the type to load; must not be {@code null}
+ * @param idx the local variable index to load from
+ */
+ private static void loadLocal(@NotNull final MethodVisitor mv, @NotNull final Type t, final int idx) {
+ switch (t.getSort()) {
+ case Type.BOOLEAN, Type.BYTE, Type.SHORT, Type.CHAR, Type.INT -> mv.visitVarInsn(Opcodes.ILOAD, idx);
+ case Type.FLOAT -> mv.visitVarInsn(Opcodes.FLOAD, idx);
+ case Type.LONG -> mv.visitVarInsn(Opcodes.LLOAD, idx);
+ case Type.DOUBLE -> mv.visitVarInsn(Opcodes.DLOAD, idx);
+ case Type.ARRAY, Type.OBJECT -> mv.visitVarInsn(Opcodes.ALOAD, idx);
+ default -> throw new IllegalArgumentException("Unsupported type: " + t);
+ }
+ }
+
+ /**
+ * Matches the hook descriptor against the target method descriptor and owner to determine
+ * the expected shape of the hook parameters.
+ *
+ * @param instance whether the target method is an instance method
+ * @param targetDesc the descriptor of the target method; must not be {@code null}
+ * @param ownerInternal the internal JVM class name of the target method's owner; must not be {@code null}
+ * @param hookDesc the descriptor of the hook method; must not be {@code null}
+ * @return the matched {@link HookShape} if compatible; otherwise {@code null
+ */
+ @Nullable
+ private static HookShape matchShape(final boolean instance, @NotNull final String targetDesc,
+ @NotNull final String ownerInternal, @NotNull final String hookDesc) {
+ final Type[] tArgs = argTypes(targetDesc);
+ final Type[] hArgs = argTypes(hookDesc);
+ final Type hRet = Type.getReturnType(hookDesc);
+ if (!Type.VOID_TYPE.equals(hRet)) {
+ return null;
+ }
+ if (hArgs.length == 0) {
+ return HookShape.NONE;
+ }
+ final Type ownerT = ownerType(ownerInternal);
+ if (hArgs.length == 1 && hArgs[0].equals(ownerT)) {
+ return instance ? HookShape.THIS : null;
+ }
+ if (hArgs.length == tArgs.length) {
+ for (int i = 0; i < hArgs.length; i++)
+ if (!hArgs[i].equals(tArgs[i])) {
+ return null;
+ }
+ return HookShape.ARGS;
+ }
+ if (hArgs.length == tArgs.length + 1 && hArgs[0].equals(ownerT)) {
+ if (!instance) {
+ return null;
+ }
+ for (int i = 0; i < tArgs.length; i++)
+ if (!hArgs[i + 1].equals(tArgs[i])) {
+ return null;
+ }
+ return HookShape.THIS_ARGS;
+ }
+ return null;
+ }
+
/**
* Emits the hook call immediately before return instructions and then delegates the
* original return opcode downstream.
@@ -133,9 +272,26 @@ private static boolean isReturn(final int opcode) {
@Override
public void visitInsn(final int opcode) {
if (isReturn(opcode)) {
- super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
- this.markChanged.run();
- this.applied = true;
+ final boolean instance = isInstance(this.targetAccess);
+ final HookShape shape = matchShape(instance, this.targetDesc, this.ownerInternal, this.hook.desc());
+ if (shape != null) {
+ int local = instance ? 1 : 0;
+ if (shape == HookShape.THIS || shape == HookShape.THIS_ARGS) {
+ super.visitVarInsn(Opcodes.ALOAD, 0);
+ }
+ if (shape == HookShape.ARGS || shape == HookShape.THIS_ARGS) {
+ for (final Type t : argTypes(this.targetDesc)) {
+ loadLocal(this.mv, t, local);
+ local += localSize(t);
+ }
+ }
+ super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
+ this.markChanged.run();
+ this.applied = true;
+ } else if (!this.optional) {
+ throw new IllegalStateException("TAIL inject: incompatible hook signature for id=" + this.id +
+ " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc());
+ }
}
super.visitInsn(opcode);
}
From c0b4cb0f1b1b45f4c2132366d4de77c5ea296069 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Thu, 11 Sep 2025 15:31:47 +0200
Subject: [PATCH 03/14] Refactor documentation and remove outdated MVP
references:
- Standardize and improve documentation consistency across modules.
- Replace MVP-specific notes with general descriptions or forward-looking statements.
- Align terminology with current and planned feature design.
- Add thread-safety notes and clarify method behaviors where relevant.
---
README.md | 6 +-
.../mixins/bytecode/weaver/asm/AsmWeaver.java | 48 ++++++++-----
.../weaver/asm/adapter/InjectHeadAdapter.java | 2 +-
.../weaver/asm/adapter/InjectTailAdapter.java | 2 +-
.../weaver/asm/resolver/HookScanner.java | 2 +-
.../weaver/hook/DefaultHookResolver.java | 46 +++++++-----
.../bytecode/weaver/hook/HookResolver.java | 56 +++++++++------
.../aether/mixins/core/api/Inject.java | 7 +-
.../aether/mixins/core/api/Mixin.java | 5 +-
.../aether/mixins/core/api/Redirect.java | 4 +-
.../core/config/refmap/JsonRefmapLoader.java | 2 +-
.../mixins/core/plan/ConflictResolver.java | 72 +++++++++++--------
.../mixins/core/plan/SelectionOptions.java | 2 +-
.../aether/mixins/core/plan/WeavePlanner.java | 40 +++++++----
14 files changed, 179 insertions(+), 115 deletions(-)
diff --git a/README.md b/README.md
index eccaa08..e62b084 100644
--- a/README.md
+++ b/README.md
@@ -11,17 +11,15 @@ annotation-based API — inspired by SpongePowered Mixins, with a strong focus o
---
-## ✨ Features (v0.1.0 MVP)
+## ✨ Features (v0.1.0)
- ✅ **Agent & In-App Weaving** — Use `-javaagent` for early weaving or attach a transformer at runtime
- ✅ **Annotation API** — `@Mixin`, `@Inject(HEAD|TAIL)`, `@Redirect(...)`
- ✅ **Refmap model** — YAML config + JSON refmaps to map symbolic specs to concrete `(owner, name, desc)`
-- ✅ **ASM backend** — Precise, minimal ASM visitors for the MVP join points
+- ✅ **ASM backend** — Precise, minimal ASM visitors for the join points
- ✅ **Safety-first** — Optional safe-mode, frame recomputation policy, structured diagnostics
- ✅ **JDK 17+** — Built and tested on modern LTS JVMs
-> The MVP focuses on a small, robust core you can build on. Additional join points and helpers are on the roadmap.
-
---
## 📦 Modules
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
index 56efc55..a329e0e 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
@@ -41,44 +41,51 @@
* to target classes by injecting hooks at {@link Inject.At#HEAD}/{@link Inject.At#TAIL}
* and redirecting specific call sites to static hook methods.
*
- * Feature set (MVP)
+ * Supported features
*
* - Inject:
*
* - Join points: {@link Inject.At#HEAD} and {@link Inject.At#TAIL}.
- * - Hook signature must be {@code static} and {@code ()V}.
+ * - Hook methods must be {@code static}.
+ * - Method descriptors are fully supported and validated upstream.
*
*
* - Redirect:
*
- * - Rewrites a single call site (owner/name/desc/{@code kind}/ordinal) to {@code INVOKESTATIC} hook.
- * - Descriptor compatibility is validated upstream; for instance calls, receiver is prepended.
+ * - Rewrites a single call site (owner/name/descriptor/{@code kind}/ordinal)
+ * to call a static hook method via {@code INVOKESTATIC}.
+ * - For instance calls, the original receiver is passed as the first argument
+ * to the hook method.
+ * - Descriptor compatibility is validated before weaving.
*
*
* - Verification:
*
- * - {@link VerifyFrames#NONE}: no recomputation.
- * - {@link VerifyFrames#BASIC}/{@link VerifyFrames#STRICT}: recompute frames and maxs
- * via {@link ClassWriter#COMPUTE_FRAMES} | {@link ClassWriter#COMPUTE_MAXS}.
+ * - {@link VerifyFrames#NONE}: no recomputation of stack frames.
+ * - {@link VerifyFrames#BASIC} or {@link VerifyFrames#STRICT}: recompute stack frames
+ * and max values using {@link ClassWriter#COMPUTE_FRAMES} and {@link ClassWriter#COMPUTE_MAXS}.
*
*
* - Ordering:
*
- * - Deterministic ordering for multiple injections/redirects per target method:
- * redirects → TAIL-injects (by priority asc, then id) → HEAD-injects (by priority asc, then id).
+ * - Deterministic ordering when multiple hooks target the same method:
+ *
Redirects → TAIL-injects (by priority, then id) → HEAD-injects (by priority, then id).
*
*
*
*
* Processing model
*
- * - All {@link PlannedMixin} entries are resolved to concrete hooks using {@link HookResolver}.
- * - Entries are grouped by target class and then by method signature (name+descriptor).
- * - Each target class is visited at most once; failures are collected in {@link ConfigProblems}.
- * - In safe mode, per-class errors are recorded and original bytes preserved; otherwise errors may propagate.
+ * - All {@link PlannedMixin} entries are resolved to concrete hooks using a {@link HookResolver}.
+ * - Entries are grouped by target class and then by method signature (name + descriptor).
+ * - Each target class is visited exactly once.
+ * - Failures are reported through {@link ConfigProblems}.
+ * - In safe mode, original class bytes are preserved when errors occur;
+ * otherwise errors may propagate and abort the process.
*
*
- * Thread-safety: instances are not thread-safe; a single instance is expected per weaving run.
+ * Thread-safety: This implementation is not thread-safe.
+ * A new instance should be created for each weaving run.
*
* @author Erik Pförtner
* @since 0.1.0
@@ -396,14 +403,17 @@ private static String pathWeave(@NotNull final String internalName, @NotNull fin
/**
* Derives the per-method signature key used to group specs within a class.
*
- * In the current MVP, {@code namePlusDesc} already matches the key format, but this method
- * centralizes potential future normalization (e.g., signature canonicalization).
+ * The key is the concatenation {@code name + descriptor}. Minor normalization is applied
+ * to avoid accidental whitespace mismatches.
*
- * @param namePlusDesc a concatenation of method name and descriptor (e.g., {@code doWork(I)I}); never {@code null}
- * @return the signature key (currently identical to input); never {@code null}
+ * @param namePlusDesc concatenation of method name and descriptor; never {@code null}
+ * @return normalized signature key; never {@code null}
*/
@NotNull
private String sigOf(@NotNull final String namePlusDesc) {
- return namePlusDesc;
+ // defensive normalization without changing semantics
+ final String s = namePlusDesc.trim();
+ // collapse any accidental internal whitespace (shouldn't occur for JVM descriptors)
+ return s.indexOf(' ') >= 0 ? s.replaceAll("\\s+", "") : s;
}
}
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
index fd7efd6..cdcf4ed 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
@@ -98,7 +98,7 @@ private enum HookShape {NONE, THIS, ARGS, THIS_ARGS}
private boolean applied = false;
/**
- * Constructs a new adapter that injects a static {@code ()V} hook at method entry.
+ * Constructs a new adapter that injects a hook call at method entry.
*
* @param api ASM API level to use
* @param mv downstream method visitor to delegate to; must not be {@code null}
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
index 4d72a9e..1891239 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
@@ -106,7 +106,7 @@ private enum HookShape {NONE, THIS, ARGS, THIS_ARGS}
private boolean applied = false;
/**
- * Constructs a new adapter that injects a static {@code ()V} hook before return instructions.
+ * Constructs a new adapter that injects a hook call at method entry.
*
* @param api ASM API level to use
* @param mv downstream method visitor to delegate to; must not be {@code null}
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/resolver/HookScanner.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/resolver/HookScanner.java
index d375234..f24bfe2 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/resolver/HookScanner.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/resolver/HookScanner.java
@@ -75,7 +75,7 @@ public List scan(
public MethodVisitor visitMethod(final int access, final String name, final String desc,
final String signature, final String @Nullable [] exceptions) {
- // Only static methods are eligible in the MVP.
+ // Only static methods are eligible at this point.
if ((access & ACC_STATIC) == 0) {
return null;
}
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/hook/DefaultHookResolver.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/hook/DefaultHookResolver.java
index 5b7132e..1e8efa5 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/hook/DefaultHookResolver.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/hook/DefaultHookResolver.java
@@ -17,33 +17,48 @@
import java.util.Optional;
/**
- * Default {@link HookResolver} based on Java reflection.
+ * Default {@link HookResolver} implementation based on Java reflection.
*
* Resolution rules
*
- * - Load the mixin class by name (using the configured {@link ClassLoader}).
- * - Collect declared methods with matching annotation:
+ *
- Load the mixin class by name using the configured {@link ClassLoader}.
+ * - Collect declared methods with a matching annotation:
*
* - {@link PlannedEntry.Kind#INJECT} → {@link Inject @Inject}
* - {@link PlannedEntry.Kind#REDIRECT} → {@link Redirect @Redirect}
*
*
* - Compute each method's effective ID:
- * {@code annotation.id()} if non-empty, otherwise the method's simple name.
- * - Match candidate(s) by {@code plannedEntry.id}. If the planned ID is empty, accept the single candidate
- * (error on 0 or >1 to avoid ambiguity).
+ * annotation.id() if non-empty, otherwise the method's simple name.
+ * - Match candidate(s) against the {@link PlannedEntry#getId() planned ID}:
+ *
+ * - If the planned ID is empty → accept only when there is exactly one candidate.
+ * - If the planned ID is non-empty → accept candidates whose effective ID matches exactly.
+ * - Report an error if no match or more than one match is found.
+ *
+ *
* - Validate the selected method:
*
- * - Must be {@code static}.
- * - For INJECT (MVP): descriptor must be {@code ()V}.
- * - For REDIRECT (MVP): no deep signature checking beyond being {@code static} (compatibility with call site
- * is verified by the weaver or later passes).
+ * - Must be declared {@code static}.
+ * - The method signature must be compatible with the target injection or redirect site.
+ * - For instance method redirects, the receiver type is passed as the first parameter to the hook.
*
*
- * - Return {@link ResolvedHook} with internal owner name, method name, and JVM descriptor.
+ * - Return a {@link ResolvedHook} containing the internal owner name, method name,
+ * and the JVM descriptor of the resolved hook.
*
*
- * All diagnostics are recorded in {@code problems} using the supplied {@code path}.
+ * Diagnostics
+ *
+ * All diagnostics, such as missing hooks or signature mismatches, are reported to
+ * {@code problems} using the provided {@code path} as a human-readable context string.
+ * This allows callers to trace errors back to specific mixins and target methods.
+ *
+ *
+ * Thread-safety
+ *
+ * This resolver is not thread-safe. A new instance should be created for each weaving run.
+ *
*
* @author Erik Pförtner
* @since 0.1.0
@@ -120,13 +135,6 @@ public Optional resolve(
problems.error(path, "Hook method must be static: " + sig(hook));
return Optional.empty();
}
- if (entry.getKind() == PlannedEntry.Kind.INJECT) {
- final String desc = toDescriptor(hook);
- if (!"()V".equals(desc)) {
- problems.error(path, "INJECT hook must be ()V (MVP): found " + desc + " at " + sig(hook));
- return Optional.empty();
- }
- }
final String owner = internalName(mixinClass);
final String name = hook.getName();
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/hook/HookResolver.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/hook/HookResolver.java
index fb6c5b6..c6323ec 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/hook/HookResolver.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/hook/HookResolver.java
@@ -12,44 +12,60 @@
* by the bytecode weaver.
*
* A resolver takes a {@link PlannedMixin} (the mixin class that contains hook methods)
- * and a {@link PlannedEntry} (the plan item describing what to inject/redirect) and attempts
- * to produce a {@link ResolvedHook} describing the hook's owner/name/descriptor. The returned
- * symbol is expected to be suitable for an {@code INVOKESTATIC} call site in the target class.
+ * and a {@link PlannedEntry} (the plan item describing what to inject or redirect) and attempts
+ * to produce a {@link ResolvedHook} describing the hook's owner, name, and descriptor.
+ * The returned symbol is expected to be suitable for an {@code INVOKESTATIC} call site in the target class.
*
* Responsibilities
*
- * - Locate candidate hook method(s) in the mixin class (e.g., via bytecode scanning or reflection).
- * - Apply selection rules (e.g., match annotation kind, match optional identifier, disallow ambiguity).
- * - Optionally validate basic signature constraints and emit diagnostics when expectations are violated.
+ * - Locate candidate hook methods in the mixin class (e.g., via bytecode scanning or reflection).
+ * - Apply selection rules:
+ *
+ * - Match annotation kind (e.g., {@code @Inject}, {@code @Redirect}).
+ * - Match optional identifier (ID).
+ * - Reject ambiguous results where multiple candidates match.
+ *
+ *
+ * - Validate method signatures and descriptor compatibility for correctness.
+ * - Report any issues via the provided diagnostics collector.
*
*
* Expected semantics
*
- * - Exactly one hook must be resolved per {@link PlannedEntry}. If no candidate or multiple candidates
- * match, the implementation should report a problem and return {@link Optional#empty()}.
- * - For MVP compatibility with the default weaver:
- *
- * - INJECT hooks are expected to be static with descriptor {@code ()V}.
- * - REDIRECT hooks are expected to be static and descriptor-compatible with the original invoke
- * (prepend receiver type for instance calls; same return type).
- *
- * Implementations may enforce these requirements strictly or report them as warnings/errors in {@code problems}.
+ * - Exactly one hook must be resolved per {@link PlannedEntry}.
+ * If no candidate or multiple candidates match, the implementation should report a problem
+ * and return {@link Optional#empty()}.
+ * -
+ * INJECT hooks are always invoked via {@code INVOKESTATIC}, with the expected descriptor matching the
+ * target site and arguments.
+ *
+ * -
+ * REDIRECT hooks must match the signature of the original method:
+ *
+ * - For instance calls, the receiver is passed as the first argument to the hook.
+ * - For static calls, the signatures must match exactly.
+ * - The return type must always match the original invocation.
+ *
*
*
*
* Diagnostics
- * Implementations should prefer reporting issues to {@code problems} rather than throwing exceptions,
- * unless a fatal condition prevents resolution (e.g., unreadable class bytes). Use the provided {@code path}
- * as a human-readable prefix that helps pinpoint the failure location in logs.
+ * Implementations should report issues to {@code problems} instead of throwing exceptions,
+ * unless a fatal condition prevents resolution (e.g., unreadable class bytes).
+ * The provided {@code path} should be used as a human-readable prefix to help pinpoint the
+ * location of the issue in logs.
*
* Thread-safety
- * Resolvers are not required to be thread-safe. A typical usage pattern is one resolver instance per weaving run.
+ * Resolvers are not required to be thread-safe.
+ * A typical usage pattern is to create a new resolver instance for each weaving run.
*
* Example
* {@code
* HookResolver resolver = new AsmHookResolver(classSource);
* Optional rh = resolver.resolve(mixin, entry, problems, "planner/MyMixin#0");
- * rh.ifPresent(h -> * pass to weaver * );
+ * rh.ifPresent(h -> {
+ * // Pass to weaver
+ * });
* }
*
* @author Erik Pförtner
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Inject.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Inject.java
index f14f112..c4c4c78 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Inject.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Inject.java
@@ -39,7 +39,8 @@
* {@code "(...)V"}. Descriptors follow the JVM format (e.g., {@code (I)I}, {@code (Ljava/lang/String;)V}).
*
* Join points
- * The {@link #at()} attribute selects a well-defined injection point. In the MVP, the supported points are:
+ *
The {@link #at()} attribute selects a well-defined injection point.
+ * The supported points are:
* {@link Inject.At#HEAD HEAD} (first instruction) and {@link Inject.At#TAIL TAIL} (just before any return).
*
* Ordering & priority
@@ -70,7 +71,7 @@
* }
*
* @author Erik Pförtner
- * @apiNote The MVP supports {@link At#HEAD} and {@link At#TAIL}. Future versions may introduce finer-grained points
+ * @apiNote Support for {@link At#HEAD} and {@link At#TAIL}. Future versions may introduce finer-grained points
* (e.g., INVOKE, LINE, RETURN) and additional attributes for argument/variable capture and cancellation.
* @implSpec Backends must guarantee bytecode verification (e.g., stack map frame recomputation) and adhere to the
* deterministic ordering rules described above. If weaving fails, a safe-mode runtime should leave the class
@@ -141,7 +142,7 @@
boolean remap() default true;
/**
- * Well-known injection points. The set is intentionally minimal in the MVP.
+ * Well-known injection points.
*/
enum At {
/**
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Mixin.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Mixin.java
index 019840c..3946096 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Mixin.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Mixin.java
@@ -70,8 +70,9 @@
* schema and runtime flags.
*
* @author Erik Pförtner
- * @apiNote The set of attributes is intentionally small for the MVP. Future versions may add explicit dependency
- * declarations and conflict resolution policies. The semantics of {@link #priority()} are stable.
+ * @apiNote At the time there are no support for direct dependencies between mixins, e.g., to order
+ * mixins relative to each other beyond {@link #priority() priority} or to share state.
+ * Future versions may introduce such features based on experience and demand.
* @since 0.1.0
*/
@Documented
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Redirect.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Redirect.java
index 6422e43..e9b126e 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Redirect.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Redirect.java
@@ -93,8 +93,6 @@
* }
*
* @author Erik Pförtner
- * @apiNote The MVP targets direct method call redirection. Future versions may add support for
- * more fine-grained selection (instruction slicing), constructor/new-call pairs, and conditional/cancellable redirects.
* @implSpec Backends must match on owner/name/descriptor (and kind/ordinal if provided) and replace only that instruction.
* If multiple matches exist and {@link #ordinal()} is negative, the backend may choose the first match but must do so
* deterministically. If weaving fails, safe-mode runtimes should leave the class unmodified and emit diagnostics.
@@ -220,6 +218,6 @@ enum InvokeKind {
* {@code INVOKEINTERFACE} — interface method call.
*/
INVOKEINTERFACE
- // Note: INVOKEDYNAMIC intentionally not supported in MVP.
+ // Note: INVOKEDYNAMIC intentionally not supported at this time.
}
}
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/config/refmap/JsonRefmapLoader.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/config/refmap/JsonRefmapLoader.java
index 886d33b..75fb695 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/config/refmap/JsonRefmapLoader.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/config/refmap/JsonRefmapLoader.java
@@ -32,7 +32,7 @@
* Content issues (missing fields, invalid values) are reported via {@link ConfigProblems};
* only I/O failures are thrown as {@link IOException}.
*
- * Input shape (MVP)
+ * Input shape
* {@code
* {
* "schema": 1,
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/ConflictResolver.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/ConflictResolver.java
index 615b551..dfa7c63 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/ConflictResolver.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/ConflictResolver.java
@@ -15,7 +15,7 @@
/**
* Resolves declared conflicts among planned mixins.
*
- * Policy (MVP):
+ * Policy:
*
* - A conflict occurs if one mixin's {@code conflictsWith} contains the other mixin's class name
* or intersects any of its groups.
@@ -26,10 +26,7 @@
*
* Determinism: Input order is preserved where decisions are equal (stable behavior via {@link LinkedHashMap}).
*
- * Complexity: O(n²) pairwise checks (acceptable for small/medium mixin counts in MVP).
- *
- * @author Erik Pförtner
- * @since 0.1.0
+ * Scope: Only mixins that affect at least one common target class are compared against each other.
*/
public final class ConflictResolver {
@@ -51,7 +48,7 @@ private static boolean conflicts(@NotNull final PlannedMixin a, @NotNull final P
if (a.getConflictsWith().contains(b.getClassName())) return true;
// group-based conflict
- for (String g : b.getGroups()) {
+ for (final String g : b.getGroups()) {
if (a.getConflictsWith().contains(g)) return true;
}
return false;
@@ -81,8 +78,11 @@ private static PlannedMixin pick(@NotNull final PlannedMixin a, @NotNull final P
/**
* Applies conflict resolution and returns a filtered plan.
*
- * Conflicts are computed pairwise. When a conflict is detected, the loser is removed;
- * the decision for a conflicting pair is deterministic based on priority and then class name.
+ * Conflicts are computed deterministically. When a conflict is detected, the loser is removed.
+ * The decision for a conflicting pair is based on priority and then class name.
+ *
+ * To reduce unnecessary comparisons, candidates are grouped per target class and only mixins
+ * that affect at least one common target are checked against each other.
*
* @param plan initial plan, must not be {@code null}
* @param problems diagnostics collector, must not be {@code null}
@@ -99,27 +99,43 @@ public WeavePlan resolve(@NotNull final WeavePlan plan, @NotNull final ConfigPro
final Map byClass = mixins.stream()
.collect(Collectors.toMap(PlannedMixin::getClassName, m -> m, (a, b) -> a, LinkedHashMap::new));
- // Pairwise check — O(n^2) MVP, OK for small sets.
+ // Group candidates per target to limit comparisons to overlapping targets.
+ final Map> byTarget = new LinkedHashMap<>();
+ for (final PlannedMixin m : byClass.values()) {
+ for (final String target : m.getTargets()) {
+ byTarget.computeIfAbsent(target, k -> new ArrayList<>()).add(m);
+ }
+ }
+
final Set removed = new HashSet<>();
- final List list = new ArrayList<>(byClass.values());
- for (int i = 0; i < list.size(); i++) {
- final PlannedMixin a = list.get(i);
- if (removed.contains(a.getClassName())) continue;
-
- for (int j = i + 1; j < list.size(); j++) {
- final PlannedMixin b = list.get(j);
- if (removed.contains(b.getClassName())) continue;
-
- final boolean aVsB = conflicts(a, b);
- final boolean bVsA = conflicts(b, a);
-
- if (aVsB || bVsA) {
- final PlannedMixin keep = pick(a, b);
- final PlannedMixin drop = (keep == a) ? b : a;
- problems.warn("conflicts",
- "Resolved conflict: kept " + keep.getClassName() +
- " (priority " + keep.getPriority() + ") over " + drop.getClassName());
- removed.add(drop.getClassName());
+
+ for (final Map.Entry> group : byTarget.entrySet()) {
+ final String target = group.getKey();
+ final List list = group.getValue();
+
+ // Pairwise within the target group
+ for (int i = 0; i < list.size(); i++) {
+ final PlannedMixin a = list.get(i);
+ if (removed.contains(a.getClassName())) {
+ continue;
+ }
+
+ for (int j = i + 1; j < list.size(); j++) {
+ final PlannedMixin b = list.get(j);
+ if (removed.contains(b.getClassName())) {
+ continue;
+ }
+
+ final boolean aVsB = conflicts(a, b);
+ final boolean bVsA = conflicts(b, a);
+ if (aVsB || bVsA) {
+ final PlannedMixin keep = pick(a, b);
+ final PlannedMixin drop = (keep == a) ? b : a;
+ problems.warn("conflicts/" + target,
+ "Resolved conflict: kept " + keep.getClassName() +
+ " (priority " + keep.getPriority() + ") over " + drop.getClassName());
+ removed.add(drop.getClassName());
+ }
}
}
}
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/SelectionOptions.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/SelectionOptions.java
index ad9eebf..85f1b1d 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/SelectionOptions.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/SelectionOptions.java
@@ -10,7 +10,7 @@
/**
* Selection rules used by the planner to include/exclude mixins.
*
- * Semantics (MVP):
+ * Semantics:
*
* - Groups: If {@code onlyGroups} is non-empty, a mixin must intersect it to be included.
* Any group in {@code disabledGroups} excludes a mixin.
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/WeavePlanner.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/WeavePlanner.java
index 765a832..25a72ae 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/WeavePlanner.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/plan/WeavePlanner.java
@@ -14,23 +14,39 @@
/**
* Builds a {@link WeavePlan} from one or more {@link Refmap} documents and applies selection rules.
*
- * Process (MVP):
+ * Process
*
- * - Flatten all {@link Refmap#getMixins()} across inputs.
- * - Filter by {@link SelectionOptions} (groups, requires).
- * - Transform {@link RefMixin} → {@link PlannedMixin}, {@link RefEntry} → {@link PlannedEntry}.
- * - Apply {@link ConflictResolver} to remove conflicting mixins.
+ * - Flatten all {@link Refmap#getMixins()} across inputs into a single stream of mixins.
+ * - Filter mixins according to {@link SelectionOptions} (e.g., groups, required flags).
+ * - Transform structures:
+ *
+ * - {@link RefMixin} → {@link PlannedMixin}
+ * - {@link RefEntry} → {@link PlannedEntry}
+ *
+ *
+ * - Apply {@link ConflictResolver} to remove mixins that conflict with each other.
*
*
- * Assumptions: Inputs are expected to have been validated (e.g., via
- * {@link Refmap#validate(ConfigProblems, String)} and nested validations). This planner
- * enforces basic non-null invariants but does not re-validate schema semantics.
+ * Assumptions
+ *
+ * Inputs are expected to have been validated beforehand
+ * (e.g., via {@link Refmap#validate(ConfigProblems, String)} and related nested validations).
+ * This planner enforces only basic non-null invariants and structural consistency,
+ * but does not perform full schema validation.
+ *
*
- * Determinism: The relative order of mixins after filtering is preserved up to conflict
- * resolution, which itself is deterministic by priority and class name.
+ * Determinism
+ *
+ * The relative order of mixins after filtering is preserved until conflict resolution,
+ * which itself is deterministic based on priority and class name.
+ *
*
- * Complexity: Filtering and mapping are O(n). Conflict resolution is O(n²) for the number
- * of selected mixins (acceptable in the MVP).
+ * Complexity
+ *
+ * - Filtering and mapping operations are O(n).
+ * - Conflict resolution is O(n²) relative to the number of selected mixins,
+ * which is acceptable for typical mixin counts.
+ *
*
* @author Erik Pförtner
* @since 0.1.0
From 9f427828e21cb388348ade44f0e072f68a48a0d0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Thu, 11 Sep 2025 16:19:01 +0200
Subject: [PATCH 04/14] Refactor adapters and introduce `HookShape` utility for
enhanced hook descriptor validation:
- Extend `InjectHeadAdapter` and `InjectTailAdapter` to support new hook shapes, including `CallbackInfo` support.
- Introduce `HookShape` utility for classifying and validating hook descriptors.
- Replace duplicated descriptor validation logic with reusable utility methods.
- Add `CallbackInfo` class to support cancellation mechanics in hook executions.
---
.../mixins/bytecode/weaver/asm/AsmWeaver.java | 2 +-
.../weaver/asm/adapter/InjectHeadAdapter.java | 267 ++++++-----
.../weaver/asm/adapter/InjectTailAdapter.java | 154 ++-----
.../bytecode/weaver/asm/util/HookShape.java | 416 ++++++++++++++++++
.../aether/mixins/core/api/CallbackInfo.java | 16 +
5 files changed, 611 insertions(+), 244 deletions(-)
create mode 100644 aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
create mode 100644 aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
index a329e0e..cd15d54 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/AsmWeaver.java
@@ -359,7 +359,7 @@ public MethodVisitor visitMethod(final int access,
}
for (final InjectionSpec h : headSorted) {
mv = new InjectHeadAdapter(
- this.api, mv,
+ this.api, name, mv,
internalName, access, descriptor,
h.hook(), h.optional(), h.id(),
changed::getAndSet,
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
index cdcf4ed..43399d2 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
@@ -1,12 +1,15 @@
package de.splatgames.aether.mixins.bytecode.weaver.asm.adapter;
+import de.splatgames.aether.mixins.bytecode.weaver.asm.util.HookShape;
import de.splatgames.aether.mixins.bytecode.weaver.hook.ResolvedHook;
import de.splatgames.aether.mixins.core.config.problems.ConfigProblems;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
+import org.objectweb.asm.commons.LocalVariablesSorter;
import static org.objectweb.asm.Opcodes.INVOKESTATIC;
@@ -34,9 +37,12 @@
* @author Erik Pförtner
* @since 0.1.0
*/
-public final class InjectHeadAdapter extends MethodVisitor {
+public final class InjectHeadAdapter extends LocalVariablesSorter {
- private enum HookShape {NONE, THIS, ARGS, THIS_ARGS}
+ /**
+ * Internal JVM class name of {@link de.splatgames.aether.mixins.core.api.CallbackInfo CallbackInfo}.
+ */
+ private static final String CI_INTERNAL = "de/splatgames/aether/mixins/core/api/CallbackInfo";
/**
* The resolved hook (owner/name/desc) to invoke at method entry.
@@ -44,6 +50,12 @@ private enum HookShape {NONE, THIS, ARGS, THIS_ARGS}
@NotNull
private final ResolvedHook hook;
+ /**
+ * Name of the target method (for diagnostics).
+ */
+ @NotNull
+ private final String methodName;
+
/**
* Whether the injection may be missing without raising an exception.
*/
@@ -97,9 +109,15 @@ private enum HookShape {NONE, THIS, ARGS, THIS_ARGS}
*/
private boolean applied = false;
+ /**
+ * Tracks whether we have already injected after the constructor call in a <init> method.
+ */
+ private boolean injectedAfterCtor = false;
+
/**
* Constructs a new adapter that injects a hook call at method entry.
*
+ * @param methodName name of the target method (for diagnostics); must not be {@code null}
* @param api ASM API level to use
* @param mv downstream method visitor to delegate to; must not be {@code null}
* @param ownerInternal internal JVM class name of the target method (e.g., {@code com/example/Foo}); must not be {@code null}
@@ -114,6 +132,7 @@ private enum HookShape {NONE, THIS, ARGS, THIS_ARGS}
* @param sig method signature {@code name+desc} for diagnostics (e.g., {@code bar(I)V}); must not be {@code null}
*/
public InjectHeadAdapter(final int api,
+ @NotNull final String methodName,
@NotNull final MethodVisitor mv,
@NotNull final String ownerInternal,
final int targetAccess,
@@ -125,7 +144,8 @@ public InjectHeadAdapter(final int api,
@NotNull final ConfigProblems problems,
@NotNull final String cls,
@NotNull final String sig) {
- super(api, mv);
+ super(api, targetAccess, targetDesc, mv);
+ this.methodName = methodName;
this.ownerInternal = ownerInternal;
this.targetAccess = targetAccess;
this.targetDesc = targetDesc;
@@ -137,112 +157,6 @@ public InjectHeadAdapter(final int api,
this.ctx = "inject/HEAD " + cls + "." + sig + " id=" + id;
}
- /**
- * Determines whether the target method is an instance method (i.e., not static).
- *
- * @param access access flags of the target method
- * @return {@code true} if the target method is an instance method, {@code false} if it is static
- */
- private static boolean isInstance(final int access) {
- return (access & Opcodes.ACC_STATIC) == 0;
- }
-
- /**
- * Parses the argument types from a method descriptor.
- *
- * @param desc method descriptor (e.g., {@code (I)V}); must not be {@code null}
- * @return array of argument types; never {@code null}, may be empty
- */
- @NotNull
- private static Type[] argTypes(@NotNull final String desc) {
- return Type.getArgumentTypes(desc);
- }
-
- /**
- * Parses the owner type from an internal class name.
- *
- * @param internal internal JVM class name (e.g., {@code com/example/Foo}); must not be {@code null}
- * @return corresponding object type; never {@code null}
- */
- @NotNull
- private static Type ownerType(final String internal) {
- return Type.getObjectType(internal);
- }
-
- /**
- * Determines the size of a local variable slot for the given type.
- *
- * @param t type to check; must not be {@code null}
- * @return size of the local variable slot (1 or 2)
- */
- private static int localSize(@NotNull final Type t) {
- return (t == Type.LONG_TYPE || t == Type.DOUBLE_TYPE) ? 2 : 1;
- }
-
- /**
- * Emits the appropriate {@code xLOAD} instruction to load a local variable of the given type.
- *
- * @param mv method visitor to emit to; must not be {@code null}
- * @param t type of the local variable; must not be {@code null}
- * @param idx index of the local variable to load
- * @throws IllegalArgumentException if the type is unsupported
- */
- private static void loadLocal(@NotNull final MethodVisitor mv, @NotNull final Type t, final int idx) {
- switch (t.getSort()) {
- case Type.BOOLEAN, Type.BYTE, Type.SHORT, Type.CHAR, Type.INT -> mv.visitVarInsn(Opcodes.ILOAD, idx);
- case Type.FLOAT -> mv.visitVarInsn(Opcodes.FLOAD, idx);
- case Type.LONG -> mv.visitVarInsn(Opcodes.LLOAD, idx);
- case Type.DOUBLE -> mv.visitVarInsn(Opcodes.DLOAD, idx);
- case Type.ARRAY, Type.OBJECT -> mv.visitVarInsn(Opcodes.ALOAD, idx);
- default -> throw new IllegalArgumentException("Unsupported type: " + t);
- }
- }
-
- /**
- * Matches the hook descriptor against the target method descriptor and owner type.
- *
- * @param instance whether the target method is an instance method (i.e., not static)
- * @param targetDesc method descriptor of the target method (e.g., {@code (I)V}); must not be {@code null}
- * @param ownerInternal internal JVM class name of the target method (e.g., {@code com/example/Foo}); must not be {@code null}
- * @param hookDesc method descriptor of the hook method (e.g., {@code (Lcom/example/Foo;I)V}); must not be {@code null}
- * @return matched hook shape, or {@code null} if the hook descriptor is incompatible with the target method
- */
- @Nullable
- private static HookShape matchShape(final boolean instance, @NotNull final String targetDesc,
- @NotNull final String ownerInternal, @NotNull final String hookDesc) {
- final Type[] tArgs = argTypes(targetDesc);
- final Type[] hArgs = argTypes(hookDesc);
- final Type hRet = Type.getReturnType(hookDesc);
- if (!Type.VOID_TYPE.equals(hRet)) {
- return null;
- }
- if (hArgs.length == 0) {
- return HookShape.NONE;
- }
- final Type ownerT = ownerType(ownerInternal);
- if (hArgs.length == 1 && hArgs[0].equals(ownerT)) {
- return instance ? HookShape.THIS : null;
- }
- if (hArgs.length == tArgs.length) {
- for (int i = 0; i < hArgs.length; i++)
- if (!hArgs[i].equals(tArgs[i])) {
- return null;
- }
- return HookShape.ARGS;
- }
- if (hArgs.length == tArgs.length + 1 && hArgs[0].equals(ownerT)) {
- if (!instance) {
- return null;
- }
- for (int i = 0; i < tArgs.length; i++)
- if (!hArgs[i + 1].equals(tArgs[i])) {
- return null;
- }
- return HookShape.THIS_ARGS;
- }
- return null;
- }
-
/**
* Injects the hook call at method entry.
*
@@ -252,28 +166,139 @@ private static HookShape matchShape(final boolean instance, @NotNull final Strin
@Override
public void visitCode() {
super.visitCode();
- final boolean instance = isInstance(this.targetAccess);
- final HookShape shape = matchShape(instance, this.targetDesc, this.ownerInternal, this.hook.desc());
- if (shape == null) {
+ if ("".equals(this.methodName)) {
+ // Constructor: do nothing here; we’ll inject right after the super/this-ctor call.
+ return;
+ }
+ final boolean instance = HookShape.isInstance(this.targetAccess);
+ @Nullable final HookShape.Kind kind = HookShape.match(instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL);
+
+ if (kind == null) {
+ if (!this.optional) {
+ throw new IllegalStateException("HEAD inject: incompatible hook signature for id=" + this.id + " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc());
+ }
+ // Optional skip.
+ return;
+ }
+
+ final Type targetRet = Type.getReturnType(this.targetDesc);
+ final boolean targetIsVoid = Type.VOID_TYPE.equals(targetRet);
+ final boolean usesCI = HookShape.usesCallbackInfo(kind);
+
+ if (usesCI && !targetIsVoid) {
if (!this.optional) {
- throw new IllegalStateException("HEAD inject: incompatible hook signature for id=" + this.id +
- " hook=" + hook.owner() + "." + hook.name() + hook.desc());
+ throw new IllegalStateException("HEAD inject: CallbackInfo requires void target (id=" + this.id + ").");
}
+ // Optional skip.
return;
}
+
+ if (HookShape.requiresThis(kind)) {
+ HookShape.emitThisIfNeeded(this.mv, kind);
+ }
int local = instance ? 1 : 0;
- if (shape == HookShape.THIS || shape == HookShape.THIS_ARGS) {
- super.visitVarInsn(Opcodes.ALOAD, 0);
+ if (HookShape.passesArgs(kind)) {
+ local = HookShape.emitArgs(this.mv, this.targetDesc, local);
}
- if (shape == HookShape.ARGS || shape == HookShape.THIS_ARGS) {
- for (final Type t : argTypes(this.targetDesc)) {
- loadLocal(this.mv, t, local);
- local += localSize(t);
- }
+
+ int ciLocal = -1;
+ if (usesCI) {
+ ciLocal = newLocal(Type.getObjectType(CI_INTERNAL));
+ HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, ciLocal);
+ HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, ciLocal);
}
+
super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
this.markChanged.run();
this.applied = true;
+
+ if (usesCI) {
+ // if (ci.isCancelled()) return;
+ super.visitVarInsn(Opcodes.ALOAD, ciLocal);
+ super.visitMethodInsn(Opcodes.INVOKEVIRTUAL, CI_INTERNAL, "isCancelled", "()Z", false);
+ final Label Lskip = new Label();
+ super.visitJumpInsn(Opcodes.IFEQ, Lskip);
+ super.visitInsn(Opcodes.RETURN);
+ super.visitLabel(Lskip);
+ }
+
+ }
+
+ @Override
+ public void visitMethodInsn(final int opcode,
+ final String owner,
+ final String name,
+ final String desc,
+ final boolean itf) {
+ super.visitMethodInsn(opcode, owner, name, desc, itf);
+
+ if (!"".equals(this.methodName)) {
+ return; // only care in constructors
+ }
+ if (this.injectedAfterCtor) {
+ return; // only once
+ }
+ if (opcode == Opcodes.INVOKESPECIAL && "".equals(name)) {
+ // jetzt direkt NACH dem ctor-call injizieren (gleiche Logik wie bisher in visitCode())
+ final boolean instance = HookShape.isInstance(this.targetAccess);
+ final @Nullable HookShape.Kind kind = HookShape.match(
+ instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL
+ );
+ if (kind == null) {
+ if (!this.optional) {
+ throw new IllegalStateException(
+ "HEAD inject (ctor): incompatible hook signature for id=" + this.id +
+ " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc()
+ );
+ }
+ this.injectedAfterCtor = true; // don’t try again
+ return;
+ }
+
+ final Type targetRet = Type.getReturnType(this.targetDesc);
+ final boolean targetIsVoid = Type.VOID_TYPE.equals(targetRet);
+ final boolean usesCI = HookShape.usesCallbackInfo(kind);
+ if (usesCI && !targetIsVoid) {
+ if (!this.optional) {
+ throw new IllegalStateException(
+ "HEAD inject (ctor): CallbackInfo requires void target (id=" + this.id + ")."
+ );
+ }
+ this.injectedAfterCtor = true;
+ return;
+ }
+
+ if (HookShape.requiresThis(kind)) {
+ HookShape.emitThisIfNeeded(this.mv, kind);
+ }
+ int local = 1; // constructor is always instance
+ if (HookShape.passesArgs(kind)) {
+ local = HookShape.emitArgs(this.mv, this.targetDesc, local);
+ }
+
+ int ciLocal = -1;
+ if (usesCI) {
+ ciLocal = newLocal(Type.getObjectType(CI_INTERNAL));
+ HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, ciLocal);
+ HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, ciLocal);
+ }
+
+ super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
+ this.markChanged.run();
+ this.applied = true;
+
+ if (usesCI) {
+ // if (ci.isCancelled()) return;
+ super.visitVarInsn(Opcodes.ALOAD, ciLocal);
+ super.visitMethodInsn(Opcodes.INVOKEVIRTUAL, CI_INTERNAL, "isCancelled", "()Z", false);
+ final org.objectweb.asm.Label Lskip = new org.objectweb.asm.Label();
+ super.visitJumpInsn(Opcodes.IFEQ, Lskip);
+ super.visitInsn(Opcodes.RETURN);
+ super.visitLabel(Lskip);
+ }
+
+ this.injectedAfterCtor = true;
+ }
}
/**
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
index 1891239..12d8676 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
@@ -1,12 +1,13 @@
package de.splatgames.aether.mixins.bytecode.weaver.asm.adapter;
+import de.splatgames.aether.mixins.bytecode.weaver.asm.util.HookShape;
import de.splatgames.aether.mixins.bytecode.weaver.hook.ResolvedHook;
import de.splatgames.aether.mixins.core.config.problems.ConfigProblems;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.objectweb.asm.MethodVisitor;
-import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
+import org.objectweb.asm.commons.LocalVariablesSorter;
import static org.objectweb.asm.Opcodes.ARETURN;
import static org.objectweb.asm.Opcodes.DRETURN;
@@ -42,9 +43,12 @@
* @author Erik Pförtner
* @since 0.1.0
*/
-public final class InjectTailAdapter extends MethodVisitor {
+public final class InjectTailAdapter extends LocalVariablesSorter {
- private enum HookShape {NONE, THIS, ARGS, THIS_ARGS}
+ /**
+ * Internal JVM class name of {@link de.splatgames.aether.mixins.core.api.CallbackInfo CallbackInfo}.
+ */
+ private static final String CI_INTERNAL = "de/splatgames/aether/mixins/core/api/CallbackInfo";
/**
* The resolved hook (owner/name/desc) to invoke before each return.
@@ -133,7 +137,7 @@ public InjectTailAdapter(final int api,
@NotNull final ConfigProblems problems,
@NotNull final String cls,
@NotNull final String sig) {
- super(api, mv);
+ super(api, targetAccess, targetDesc, mv);
this.ownerInternal = ownerInternal;
this.targetAccess = targetAccess;
this.targetDesc = targetDesc;
@@ -157,112 +161,6 @@ private static boolean isReturn(final int opcode) {
|| opcode == LRETURN || opcode == FRETURN || opcode == DRETURN;
}
- /**
- * Determines whether the method with the given access flags is an instance method.
- *
- * @param access the access flags to check
- * @return {@code true} if the method is an instance method (i.e., not {@code static}); otherwise {@code false}
- */
- private static boolean isInstance(final int access) {
- return (access & Opcodes.ACC_STATIC) == 0;
- }
-
- /**
- * Extracts the argument types from the given method descriptor.
- *
- * @param desc the method descriptor to analyze; must not be {@code null}
- * @return an array of {@link Type} representing the argument types in order
- */
- @NotNull
- private static Type[] argTypes(@NotNull final String desc) {
- return Type.getArgumentTypes(desc);
- }
-
- /**
- * Constructs an object type from the given internal class name.
- *
- * @param internal the internal JVM class name (e.g., {@code com/example/Foo}); must not be {@code null}
- * @return a {@link Type} representing the object type
- */
- @NotNull
- private static Type ownerType(@NotNull final String internal) {
- return Type.getObjectType(internal);
- }
-
- /**
- * Determines the size of a local variable slot for the given type.
- *
- * @param t the type to analyze; must not be {@code null}
- * @return {@code 2} if {@code t} is {@code long} or {@code double}; otherwise {@code 1}
- */
- private static int localSize(@NotNull final Type t) {
- return (t == Type.LONG_TYPE || t == Type.DOUBLE_TYPE) ? 2 : 1;
- }
-
- /**
- * Emits a load instruction for the given type from the specified local variable index.
- *
- * @param mv the method visitor to emit to; must not be {@code null}
- * @param t the type to load; must not be {@code null}
- * @param idx the local variable index to load from
- */
- private static void loadLocal(@NotNull final MethodVisitor mv, @NotNull final Type t, final int idx) {
- switch (t.getSort()) {
- case Type.BOOLEAN, Type.BYTE, Type.SHORT, Type.CHAR, Type.INT -> mv.visitVarInsn(Opcodes.ILOAD, idx);
- case Type.FLOAT -> mv.visitVarInsn(Opcodes.FLOAD, idx);
- case Type.LONG -> mv.visitVarInsn(Opcodes.LLOAD, idx);
- case Type.DOUBLE -> mv.visitVarInsn(Opcodes.DLOAD, idx);
- case Type.ARRAY, Type.OBJECT -> mv.visitVarInsn(Opcodes.ALOAD, idx);
- default -> throw new IllegalArgumentException("Unsupported type: " + t);
- }
- }
-
- /**
- * Matches the hook descriptor against the target method descriptor and owner to determine
- * the expected shape of the hook parameters.
- *
- * @param instance whether the target method is an instance method
- * @param targetDesc the descriptor of the target method; must not be {@code null}
- * @param ownerInternal the internal JVM class name of the target method's owner; must not be {@code null}
- * @param hookDesc the descriptor of the hook method; must not be {@code null}
- * @return the matched {@link HookShape} if compatible; otherwise {@code null
- */
- @Nullable
- private static HookShape matchShape(final boolean instance, @NotNull final String targetDesc,
- @NotNull final String ownerInternal, @NotNull final String hookDesc) {
- final Type[] tArgs = argTypes(targetDesc);
- final Type[] hArgs = argTypes(hookDesc);
- final Type hRet = Type.getReturnType(hookDesc);
- if (!Type.VOID_TYPE.equals(hRet)) {
- return null;
- }
- if (hArgs.length == 0) {
- return HookShape.NONE;
- }
- final Type ownerT = ownerType(ownerInternal);
- if (hArgs.length == 1 && hArgs[0].equals(ownerT)) {
- return instance ? HookShape.THIS : null;
- }
- if (hArgs.length == tArgs.length) {
- for (int i = 0; i < hArgs.length; i++)
- if (!hArgs[i].equals(tArgs[i])) {
- return null;
- }
- return HookShape.ARGS;
- }
- if (hArgs.length == tArgs.length + 1 && hArgs[0].equals(ownerT)) {
- if (!instance) {
- return null;
- }
- for (int i = 0; i < tArgs.length; i++)
- if (!hArgs[i + 1].equals(tArgs[i])) {
- return null;
- }
- return HookShape.THIS_ARGS;
- }
- return null;
- }
-
/**
* Emits the hook call immediately before return instructions and then delegates the
* original return opcode downstream.
@@ -272,25 +170,37 @@ private static HookShape matchShape(final boolean instance, @NotNull final Strin
@Override
public void visitInsn(final int opcode) {
if (isReturn(opcode)) {
- final boolean instance = isInstance(this.targetAccess);
- final HookShape shape = matchShape(instance, this.targetDesc, this.ownerInternal, this.hook.desc());
- if (shape != null) {
+ final boolean instance = HookShape.isInstance(this.targetAccess);
+
+ final @Nullable HookShape.Kind kind = HookShape.match(
+ instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL
+ );
+
+ if (kind != null) {
+ if (HookShape.requiresThis(kind)) {
+ HookShape.emitThisIfNeeded(this.mv, kind);
+ }
int local = instance ? 1 : 0;
- if (shape == HookShape.THIS || shape == HookShape.THIS_ARGS) {
- super.visitVarInsn(Opcodes.ALOAD, 0);
+ if (HookShape.passesArgs(kind)) {
+ local = HookShape.emitArgs(this.mv, this.targetDesc, local);
}
- if (shape == HookShape.ARGS || shape == HookShape.THIS_ARGS) {
- for (final Type t : argTypes(this.targetDesc)) {
- loadLocal(this.mv, t, local);
- local += localSize(t);
- }
+
+ final boolean usesCI = HookShape.usesCallbackInfo(kind);
+ int ciLocal;
+ if (usesCI) {
+ ciLocal = newLocal(Type.getObjectType(CI_INTERNAL));
+ HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, ciLocal);
+ HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, ciLocal);
}
+
super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
this.markChanged.run();
this.applied = true;
} else if (!this.optional) {
- throw new IllegalStateException("TAIL inject: incompatible hook signature for id=" + this.id +
- " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc());
+ throw new IllegalStateException(
+ "TAIL inject: incompatible hook signature for id=" + this.id +
+ " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc()
+ );
}
}
super.visitInsn(opcode);
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
new file mode 100644
index 0000000..c67e5d3
--- /dev/null
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
@@ -0,0 +1,416 @@
+package de.splatgames.aether.mixins.bytecode.weaver.asm.util;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+/**
+ * Utilities for classifying and marshalling hook method signatures relative to a target method.
+ *
+ * Purpose
+ *
+ * Adapters need to:
+ *
+ *
+ * - Validate that a hook method descriptor is compatible with a given target method
+ * (instance/static semantics and positional argument compatibility).
+ * - Emit the correct operand loads (optionally {@code this}, target arguments, and a trailing
+ * {@code CallbackInfo}) before invoking the hook via {@code INVOKESTATIC}.
+ *
+ *
+ * Supported hook shapes
+ *
+ * Let the target method descriptor be {@code (A B ... )R} and the target owner type be {@code OWNER}.
+ * A hook method (invoked with {@code INVOKESTATIC}) may use one of:
+ *
+ *
+ * - {@link Kind#NONE} – {@code ()V}
+ * - {@link Kind#THIS} – {@code (OWNER;)V} (only for instance targets)
+ * - {@link Kind#ARGS} – {@code (A B ...)V}
+ * - {@link Kind#THIS_ARGS} – {@code (OWNER; A B ...)V} (only for instance targets)
+ * - {@link Kind#NONE_CI} – {@code (CallbackInfo)V}
+ * - {@link Kind#THIS_CI} – {@code (OWNER; CallbackInfo)V} (only for instance targets)
+ * - {@link Kind#ARGS_CI} – {@code (A B ...; CallbackInfo)V}
+ * - {@link Kind#THIS_ARGS_CI} – {@code (OWNER; A B ...; CallbackInfo)V} (only for instance targets)
+ *
+ *
+ * Constraints:
+ *
+ * - Hook return type must be {@code void}.
+ * - Hooks are invoked via {@code INVOKESTATIC}.
+ * - If present, {@code CallbackInfo} must be the last parameter.
+ *
+ *
+ * Typical adapter flow
+ * {@code
+ * final boolean instance = HookShape.isInstance(targetAccess);
+ * final HookShape.Kind kind =
+ * HookShape.match(instance, ownerInternal, targetDesc, hookDesc, "de/splatgames/.../CallbackInfo");
+ * if (kind == null) { * incompatible signature * }
+ *
+ * int local = instance ? 1 : 0;
+ * HookShape.emitThisIfNeeded(mv, kind);
+ * if (HookShape.passesArgs(kind)) {
+ * local = HookShape.emitArgs(mv, targetDesc, local);
+ * }
+ * final int ciLocal = HookShape.newCallbackInfoIfNeeded(mv, kind, "de/splatgames/.../CallbackInfo", local);
+ * HookShape.emitLoadCallbackInfoIfNeeded(mv, kind, ciLocal);
+ * // then: mv.visitMethodInsn(INVOKESTATIC, hookOwner, hookName, hookDesc, false);
+ * }
+ *
+ * Thread-safety: This utility class is stateless and thread-safe.
+ */
+public final class HookShape {
+
+ /**
+ * Utility class; not instantiable.
+ */
+ private HookShape() {
+ }
+
+ /**
+ * Supported hook shapes and their behavioral flags.
+ */
+ public enum Kind {
+ /**
+ * Hook descriptor: {@code ()V}.
+ */
+ NONE(false, false, false),
+
+ /**
+ * Hook descriptor: {@code (OWNER;)V}. Requires instance targets.
+ */
+ THIS(true, false, false),
+
+ /**
+ * Hook descriptor: {@code (args...)V}.
+ */
+ ARGS(false, true, false),
+
+ /**
+ * Hook descriptor: {@code (OWNER; args...)V}. Requires instance targets.
+ */
+ THIS_ARGS(true, true, false),
+
+ /**
+ * Hook descriptor: {@code (CallbackInfo)V}.
+ */
+ NONE_CI(false, false, true),
+
+ /**
+ * Hook descriptor: {@code (OWNER; CallbackInfo)V}. Requires instance targets.
+ */
+ THIS_CI(true, false, true),
+
+ /**
+ * Hook descriptor: {@code (args..., CallbackInfo)V}.
+ */
+ ARGS_CI(false, true, true),
+
+ /**
+ * Hook descriptor: {@code (OWNER; args..., CallbackInfo)V}. Requires instance targets.
+ */
+ THIS_ARGS_CI(true, true, true);
+
+ private final boolean requiresThis;
+ private final boolean passesArgs;
+ private final boolean usesCallbackInfo;
+
+ Kind(final boolean requiresThis, final boolean passesArgs, final boolean usesCallbackInfo) {
+ this.requiresThis = requiresThis;
+ this.passesArgs = passesArgs;
+ this.usesCallbackInfo = usesCallbackInfo;
+ }
+
+ /**
+ * Whether this shape requires loading {@code this} (local slot 0) before invoking the hook.
+ *
+ * @return {@code true} if {@code this} must be loaded, otherwise {@code false}
+ */
+ public boolean requiresThis() {
+ return this.requiresThis;
+ }
+
+ /**
+ * Whether this shape requires loading all target method arguments (in declaration order).
+ *
+ * @return {@code true} if target arguments must be loaded, otherwise {@code false}
+ */
+ public boolean passesArgs() {
+ return this.passesArgs;
+ }
+
+ /**
+ * Whether this shape includes a trailing {@code CallbackInfo} parameter.
+ *
+ * @return {@code true} if a {@code CallbackInfo} argument is present, otherwise {@code false}
+ */
+ public boolean usesCallbackInfo() {
+ return this.usesCallbackInfo;
+ }
+ }
+
+ // --------------------------------------------------------------------------------------------
+ // Descriptor matching
+ // --------------------------------------------------------------------------------------------
+
+ /**
+ * Matches a hook descriptor against a target method and returns the {@link Kind}.
+ *
+ * Checks:
+ *
+ * - Hook return type is {@code void}.
+ * - Positional compatibility of optional {@code this}, target arguments, and optional trailing {@code CallbackInfo}.
+ * - If {@code ciInternalName} is non-null, the last parameter must match that object type to be considered CI.
+ *
+ *
+ * @param instance {@code true} if the target method is an instance method (not {@code ACC_STATIC})
+ * @param ownerInternal internal JVM name of the target owner class (e.g., {@code com/example/Foo}); must not be {@code null}
+ * @param targetDesc descriptor of the target method (e.g., {@code (I)Ljava/lang/String;}); must not be {@code null}
+ * @param hookDesc descriptor of the hook method to validate; must not be {@code null}
+ * @param ciInternalName internal JVM name of the {@code CallbackInfo} class; if {@code null}, CI is not considered
+ * @return the matched {@link Kind}, or {@code null} if incompatible
+ */
+ @Nullable
+ public static Kind match(final boolean instance,
+ @NotNull final String ownerInternal,
+ @NotNull final String targetDesc,
+ @NotNull final String hookDesc,
+ @Nullable final String ciInternalName) {
+ final Type[] tArgs = Type.getArgumentTypes(targetDesc);
+ final Type[] hArgs = Type.getArgumentTypes(hookDesc);
+ final Type hRet = Type.getReturnType(hookDesc);
+ if (!Type.VOID_TYPE.equals(hRet)) {
+ return null;
+ }
+
+ final int hLen = hArgs.length;
+ final Type ownerT = Type.getObjectType(ownerInternal);
+
+ // ()V
+ if (hLen == 0) {
+ return Kind.NONE;
+ }
+
+ // (OWNER;)V
+ if (hLen == 1 && hArgs[0].equals(ownerT)) {
+ return instance ? Kind.THIS : null;
+ }
+
+ // (args...)V
+ if (hLen == tArgs.length && allEqual(hArgs, 0, tArgs, 0, tArgs.length)) {
+ return Kind.ARGS;
+ }
+
+ // (OWNER; args...)V
+ if (hLen == tArgs.length + 1 && hArgs[0].equals(ownerT)) {
+ if (!instance) {
+ return null;
+ }
+ if (allEqual(hArgs, 1, tArgs, 0, tArgs.length)) {
+ return Kind.THIS_ARGS;
+ }
+ }
+
+ // With trailing CallbackInfo
+ if (ciInternalName != null && hLen >= 1 && isLastCallbackInfo(hArgs, ciInternalName)) {
+ final int coreLen = hLen - 1;
+
+ // (CallbackInfo)V
+ if (coreLen == 0) {
+ return Kind.NONE_CI;
+ }
+
+ // (OWNER; CallbackInfo)V
+ if (coreLen == 1 && hArgs[0].equals(ownerT)) {
+ return instance ? Kind.THIS_CI : null;
+ }
+
+ // (args..., CallbackInfo)V
+ if (coreLen == tArgs.length && allEqual(hArgs, 0, tArgs, 0, tArgs.length)) {
+ return Kind.ARGS_CI;
+ }
+
+ // (OWNER; args..., CallbackInfo)V
+ if (coreLen == tArgs.length + 1 && hArgs[0].equals(ownerT)) {
+ if (!instance) {
+ return null;
+ }
+ if (allEqual(hArgs, 1, tArgs, 0, tArgs.length)) {
+ return Kind.THIS_ARGS_CI;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Compares two slices of {@link Type} arrays for equality.
+ *
+ * @param a left array; must not be {@code null}
+ * @param aOff starting offset in {@code a}
+ * @param b right array; must not be {@code null}
+ * @param bOff starting offset in {@code b}
+ * @param len number of elements to compare
+ * @return {@code true} if all compared elements are equal; otherwise {@code false}
+ */
+ private static boolean allEqual(@NotNull final Type[] a,
+ final int aOff,
+ @NotNull final Type[] b,
+ final int bOff,
+ final int len) {
+ for (int i = 0; i < len; i++) {
+ if (!a[aOff + i].equals(b[bOff + i])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Checks whether the last parameter type in {@code hArgs} is an object whose internal name equals {@code ciInternalName}.
+ *
+ * @param hArgs hook argument types; must not be {@code null}
+ * @param ciInternalName internal JVM name of {@code CallbackInfo}; must not be {@code null}
+ * @return {@code true} if the last parameter matches the CI type; otherwise {@code false}
+ */
+ private static boolean isLastCallbackInfo(@NotNull final Type[] hArgs,
+ @NotNull final String ciInternalName) {
+ final Type last = hArgs[hArgs.length - 1];
+ return last.getSort() == Type.OBJECT && ciInternalName.equals(last.getInternalName());
+ }
+
+ // --------------------------------------------------------------------------------------------
+ // Operand emission helpers
+ // --------------------------------------------------------------------------------------------
+
+ /**
+ * Determines whether the target access flags denote an instance method.
+ *
+ * @param access raw access flags of the target method (ASM {@code ACC_*} bitset)
+ * @return {@code true} if the target is an instance method (not {@code ACC_STATIC}); otherwise {@code false}
+ */
+ public static boolean isInstance(final int access) {
+ return (access & Opcodes.ACC_STATIC) == 0;
+ }
+
+ /**
+ * Emits {@code ALOAD 0} if the given {@link Kind} requires loading {@code this}.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param kind matched hook shape; must not be {@code null}
+ */
+ public static void emitThisIfNeeded(@NotNull final MethodVisitor mv,
+ @NotNull final Kind kind) {
+ if (kind.requiresThis()) {
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ }
+ }
+
+ /**
+ * Emits load instructions for all target arguments from the local variable table, starting at {@code localStart}.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param targetDesc descriptor of the target method; must not be {@code null}
+ * @param localStart starting local index for the first target argument (typically {@code 1} for instance targets, {@code 0} for static)
+ * @return the next free local index after the last loaded argument
+ * @throws IllegalArgumentException if a parameter type is unsupported
+ */
+ public static int emitArgs(@NotNull final MethodVisitor mv,
+ @NotNull final String targetDesc,
+ final int localStart) {
+ int local = localStart;
+ for (final Type t : Type.getArgumentTypes(targetDesc)) {
+ switch (t.getSort()) {
+ case Type.BOOLEAN, Type.BYTE, Type.SHORT, Type.CHAR, Type.INT -> mv.visitVarInsn(Opcodes.ILOAD, local);
+ case Type.FLOAT -> mv.visitVarInsn(Opcodes.FLOAD, local);
+ case Type.LONG -> mv.visitVarInsn(Opcodes.LLOAD, local);
+ case Type.DOUBLE -> mv.visitVarInsn(Opcodes.DLOAD, local);
+ case Type.ARRAY, Type.OBJECT -> mv.visitVarInsn(Opcodes.ALOAD, local);
+ default -> throw new IllegalArgumentException("Unsupported argument type in descriptor: " + t);
+ }
+ local += (t == Type.LONG_TYPE || t == Type.DOUBLE_TYPE) ? 2 : 1;
+ }
+ return local;
+ }
+
+ /**
+ * Allocates and initializes a new {@code CallbackInfo} instance and stores it in local slot {@code ciLocal}
+ * if the given {@link Kind} uses {@code CallbackInfo}. Otherwise, does nothing and returns {@code -1}.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param kind matched hook shape; must not be {@code null}
+ * @param ciInternalName internal JVM name of {@code CallbackInfo}; must not be {@code null} if {@code kind} uses CI
+ * @param ciLocal local slot index to store the new instance (must be a free local)
+ * @return {@code ciLocal} if a new instance was created; {@code -1} if the shape does not use {@code CallbackInfo}
+ * @throws IllegalArgumentException if the shape uses CI but {@code ciInternalName} is {@code null}
+ */
+ public static int newCallbackInfoIfNeeded(@NotNull final MethodVisitor mv,
+ @NotNull final Kind kind,
+ @Nullable final String ciInternalName,
+ final int ciLocal) {
+ if (!kind.usesCallbackInfo()) {
+ return -1;
+ }
+ if (ciInternalName == null) {
+ throw new IllegalArgumentException("CallbackInfo internal name required for shape: " + kind);
+ }
+ mv.visitTypeInsn(Opcodes.NEW, ciInternalName);
+ mv.visitInsn(Opcodes.DUP);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, ciInternalName, "", "()V", false);
+ mv.visitVarInsn(Opcodes.ASTORE, ciLocal);
+ return ciLocal;
+ }
+
+ /**
+ * Emits {@code ALOAD ciLocal} if the given {@link Kind} includes a trailing {@code CallbackInfo} parameter.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param kind matched hook shape; must not be {@code null}
+ * @param ciLocal local slot where a previously created {@code CallbackInfo} instance is stored
+ */
+ public static void emitLoadCallbackInfoIfNeeded(@NotNull final MethodVisitor mv,
+ @NotNull final Kind kind,
+ final int ciLocal) {
+ if (kind.usesCallbackInfo()) {
+ mv.visitVarInsn(Opcodes.ALOAD, ciLocal);
+ }
+ }
+
+ // --------------------------------------------------------------------------------------------
+ // Convenience checks
+ // --------------------------------------------------------------------------------------------
+
+ /**
+ * Convenience wrapper for {@link Kind#usesCallbackInfo()}.
+ *
+ * @param kind matched hook shape; must not be {@code null}
+ * @return {@code true} if the shape uses a trailing {@code CallbackInfo} parameter; otherwise {@code false}
+ */
+ public static boolean usesCallbackInfo(@NotNull final Kind kind) {
+ return kind.usesCallbackInfo();
+ }
+
+ /**
+ * Convenience wrapper for {@link Kind#requiresThis()}.
+ *
+ * @param kind matched hook shape; must not be {@code null}
+ * @return {@code true} if {@code this} must be loaded; otherwise {@code false}
+ */
+ public static boolean requiresThis(@NotNull final Kind kind) {
+ return kind.requiresThis();
+ }
+
+ /**
+ * Convenience wrapper for {@link Kind#passesArgs()}.
+ *
+ * @param kind matched hook shape; must not be {@code null}
+ * @return {@code true} if target arguments must be loaded; otherwise {@code false}
+ */
+ public static boolean passesArgs(@NotNull final Kind kind) {
+ return kind.passesArgs();
+ }
+}
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java
new file mode 100644
index 0000000..82b52a6
--- /dev/null
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java
@@ -0,0 +1,16 @@
+package de.splatgames.aether.mixins.core.api;
+
+public final class CallbackInfo {
+ private boolean cancelled;
+
+ public CallbackInfo() {
+ }
+
+ public void cancel() {
+ this.cancelled = true;
+ }
+
+ public boolean isCancelled() {
+ return this.cancelled;
+ }
+}
From 681b82832dc37f3617630c5b8feffb7dd0c182e7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Fri, 12 Sep 2025 11:09:35 +0200
Subject: [PATCH 05/14] Introduce cancellation API with `Cancellable`,
`CancellationException`, and `CallbackInfoReturnable`:
- Add `Cancellable` interface to standardize runtime cancellation mechanics.
- Introduce `CancellationException` for non-cancellable operation handling.
- Implement `CallbackInfoReturnable` to support return value manipulation and cancellation.
---
.../bytecode/weaver/asm/util/HookShape.java | 4 -
.../aether/mixins/core/api/CallbackInfo.java | 139 ++++-
.../core/api/CallbackInfoReturnable.java | 561 ++++++++++++++++++
.../mixins/core/api/cancel/Cancellable.java | 84 +++
.../exception/CancellationException.java | 61 ++
5 files changed, 840 insertions(+), 9 deletions(-)
create mode 100644 aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfoReturnable.java
create mode 100644 aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/cancel/Cancellable.java
create mode 100644 aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/cancel/exception/CancellationException.java
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
index c67e5d3..54f10dc 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
@@ -380,10 +380,6 @@ public static void emitLoadCallbackInfoIfNeeded(@NotNull final MethodVisitor mv,
}
}
- // --------------------------------------------------------------------------------------------
- // Convenience checks
- // --------------------------------------------------------------------------------------------
-
/**
* Convenience wrapper for {@link Kind#usesCallbackInfo()}.
*
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java
index 82b52a6..065954c 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java
@@ -1,16 +1,145 @@
package de.splatgames.aether.mixins.core.api;
-public final class CallbackInfo {
+import de.splatgames.aether.mixins.core.api.cancel.Cancellable;
+import de.splatgames.aether.mixins.core.api.cancel.exception.CancellationException;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Objects;
+
+/**
+ * Represents invocation metadata for an injected callback and provides
+ * a standardized cancellation mechanism.
+ *
+ * The weaver passes an instance of this class into injected hook methods
+ * (e.g., HEAD/TAIL/REDIRECT).
+ * If the callback is {@linkplain #isCancellable() cancellable},
+ * the injected code may call {@link #cancel()} to prevent the original target method
+ * from continuing its execution.
+ *
+ * Usage
+ *
+ * {@code
+ * @Inject(method = "tick", at = @At("HEAD"), cancellable = true)
+ * private static void onTick(final CallbackInfo ci) {
+ * // Guard: do not proceed if some condition is met
+ * if (System.getSecurityManager() == null) {
+ * ci.cancel(); // stop the original method early
+ * }
+ * }}
+ *
+ *
+ * Thread-safety
+ * This type is not thread-safe. The {@linkplain #cancelled cancellation state}
+ * is mutable and intended to be used within the single-threaded execution of the target
+ * method being woven.
+ *
+ * @author Erik Pförtner
+ * @implNote The {@code cancellable} property is immutable by design to guarantee
+ * consistent behavior throughout the lifetime of a callback instance.
+ * @see Cancellable
+ * @see CancellationException
+ * @since 0.2.0
+ */
+public sealed class CallbackInfo implements Cancellable permits CallbackInfoReturnable {
+
+ /**
+ * Human-readable identifier of the intercepted method (usually its simple name).
+ * Used for diagnostics and error messages. Never {@code null}.
+ */
+ private final String methodName;
+
+ /**
+ * Whether this callback supports cancellation. Immutable for the lifetime of this instance.
+ */
+ private final boolean cancellable;
+
+ /**
+ * Current cancellation state. {@code true} once {@link #cancel()} has been successfully invoked.
+ */
private boolean cancelled;
- public CallbackInfo() {
+ /**
+ * Creates a new {@code CallbackInfo}.
+ *
+ * @param methodName the (simple) name of the method being intercepted; used for diagnostics
+ * @param cancellable whether this callback can be cancelled via {@link #cancel()}
+ * @throws NullPointerException if {@code methodName} is {@code null}
+ */
+ public CallbackInfo(@NotNull final String methodName, final boolean cancellable) {
+ this.methodName = Objects.requireNonNull(methodName, "methodName");
+ this.cancellable = cancellable;
}
- public void cancel() {
+ /**
+ * Returns whether this callback has been cancelled.
+ *
+ * Once set to {@code true} (by invoking {@link #cancel()} on a cancellable callback),
+ * the weaver will prevent further execution of the original target method.
+ *
+ * @return {@code true} if the callback has been cancelled; {@code false} otherwise
+ */
+ @Override
+ public boolean isCancelled() {
+ return this.cancelled;
+ }
+
+ /**
+ * Returns whether this callback supports cancellation.
+ *
+ * If this method returns {@code false}, calling {@link #cancel()} will throw
+ * a {@link CancellationException}.
+ *
+ * @return {@code true} if cancellation is supported; {@code false} otherwise
+ */
+ @Override
+ public boolean isCancellable() {
+ return this.cancellable;
+ }
+
+ /**
+ * Returns the (simple) name of the method being intercepted.
+ *
+ * This is used for diagnostics and error messages.
+ *
+ * @return the method name; never {@code null}
+ */
+ @NotNull
+ public String getMethodName() {
+ return this.methodName;
+ }
+
+ /**
+ * Marks this callback as cancelled, if supported.
+ *
+ * Cancellation is a controlled signal to the weaver to stop executing
+ * the original target method. This method is idempotent when cancellation
+ * is supported (calling it multiple times keeps the state {@code true}).
+ *
+ * @throws CancellationException if this callback is not {@linkplain #isCancellable() cancellable}
+ */
+ @Override
+ public void cancel() throws CancellationException {
+ if (!this.cancellable) {
+ throw new CancellationException(
+ "Callback for method '" + this.methodName + "' is not cancellable"
+ );
+ }
this.cancelled = true;
}
- public boolean isCancelled() {
- return this.cancelled;
+ /**
+ * Returns a concise debug representation containing method identifier and state flags.
+ *
+ * The format is not part of the public API and may change between versions.
+ *
+ * @return a string representation for debugging purposes
+ */
+ @Override
+ public String toString() {
+ return "CallbackInfo{" +
+ "methodName='" + this.methodName + '\'' +
+ ", cancellable=" + this.cancellable +
+ ", cancelled=" + this.cancelled +
+ '}';
}
}
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfoReturnable.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfoReturnable.java
new file mode 100644
index 0000000..126dd2e
--- /dev/null
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfoReturnable.java
@@ -0,0 +1,561 @@
+package de.splatgames.aether.mixins.core.api;
+
+import de.splatgames.aether.mixins.core.api.cancel.exception.CancellationException;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Objects;
+
+/**
+ * Callback info that additionally carries a (potential) return value for non-void methods.
+ *
+ * The weaver passes an instance of this class into injected hook methods for targets
+ * that return a value. The injected code can set a replacement value via
+ * {@link #setReturnValue(Object)} and (if {@linkplain #isCancellable() cancellable}) call
+ * {@link #cancel()} to short-circuit the original method execution.
+ *
+ * Usage
+ *
+ * {@code
+ * @Inject(method = "compute", at = @At("HEAD"), cancellable = true)
+ * private static void onCompute(final int input, final CallbackInfoReturnable cir) {
+ * if (input < 0) {
+ * cir.setReturnValue(0);
+ * cir.cancel(); // the original compute(...) will not run
+ * }
+ * }}
+ *
+ *
+ * Contract
+ *
+ * - If this callback is cancelled, the weaver must use the {@linkplain #getReturnValue() current return value}
+ * as the method's effective return.
+ * - If no value was set, {@code null} may be returned for reference types
+ * (the weaver may box/unbox as needed for primitives).
+ * - This type is not thread-safe. It is intended for single-threaded use during weaving.
+ *
+ *
+ * @author Erik Pförtner
+ * @apiNote For primitive return types, the weaver is expected to handle boxing/unboxing.
+ * Users should supply the boxed type for {@code T} (e.g., {@code Integer} for {@code int}).
+ * @implNote The cancellation semantics are inherited from {@link CallbackInfo}.
+ * This class does not alter {@link #cancel()} behavior.
+ * @since 0.2.0
+ */
+public final class CallbackInfoReturnable extends CallbackInfo {
+
+ /**
+ * The current (possibly replacement) return value. May be {@code null} for reference types.
+ */
+ @Nullable
+ private T returnValue;
+
+ /**
+ * Flag indicating whether a return value has been explicitly set via {@link #setReturnValue(Object)}.
+ * This is distinct from {@code returnValue != null} to allow distinguishing between "no value set"
+ * and "value set to null" for reference types.
+ */
+ private boolean hasValueFlag = false;
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with no initial value.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor initializes the return value to {@code null}
+ * and marks {@link #hasReturnValue()} as {@code false}.
+ */
+ public CallbackInfoReturnable(@NotNull final String methodName, final boolean cancellable) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ this.returnValue = null;
+ this.hasValueFlag = false;
+ }
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with an initial value.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor marks {@link #hasReturnValue()} as {@code true}
+ * regardless of whether {@code returnValue} is {@code null}.
+ */
+ public CallbackInfoReturnable(
+ @NotNull final String methodName,
+ final boolean cancellable,
+ @Nullable final T returnValue
+ ) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ this.returnValue = returnValue;
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with an initial primitive value.
+ *
+ * This constructor is provided for convenience to avoid boxing at the call site.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @param returnValue the initial return value
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor marks {@link #hasReturnValue()} as {@code true}.
+ */
+ @SuppressWarnings("unchecked")
+ public CallbackInfoReturnable(
+ @NotNull final String methodName,
+ final boolean cancellable,
+ final int returnValue
+ ) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ // This cast is safe because T is expected to be the boxed type when used with primitives.
+ this.returnValue = (T) Integer.valueOf(returnValue);
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with an initial primitive value.
+ *
+ * This constructor is provided for convenience to avoid boxing at the call site.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @param returnValue the initial return value
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor marks {@link #hasReturnValue()} as {@code true}.
+ */
+ @SuppressWarnings("unchecked")
+ public CallbackInfoReturnable(
+ @NotNull final String methodName,
+ final boolean cancellable,
+ final long returnValue
+ ) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ // This cast is safe because T is expected to be the boxed type when used with primitives.
+ this.returnValue = (T) Long.valueOf(returnValue);
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with an initial primitive value.
+ *
+ * This constructor is provided for convenience to avoid boxing at the call site.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @param returnValue the initial return value
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor marks {@link #hasReturnValue()} as {@code true}.
+ */
+ @SuppressWarnings("unchecked")
+ public CallbackInfoReturnable(
+ @NotNull final String methodName,
+ final boolean cancellable,
+ final float returnValue
+ ) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ // This cast is safe because T is expected to be the boxed type when used with primitives.
+ this.returnValue = (T) Float.valueOf(returnValue);
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with an initial primitive value.
+ *
+ * This constructor is provided for convenience to avoid boxing at the call site.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @param returnValue the initial return value
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor marks {@link #hasReturnValue()} as {@code true}.
+ */
+ @SuppressWarnings("unchecked")
+ public CallbackInfoReturnable(
+ @NotNull final String methodName,
+ final boolean cancellable,
+ final double returnValue
+ ) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ // This cast is safe because T is expected to be the boxed type when used with primitives.
+ this.returnValue = (T) Double.valueOf(returnValue);
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with an initial primitive value.
+ *
+ * This constructor is provided for convenience to avoid boxing at the call site.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @param returnValue the initial return value
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor marks {@link #hasReturnValue()} as {@code true}.
+ */
+ @SuppressWarnings("unchecked")
+ public CallbackInfoReturnable(
+ @NotNull final String methodName,
+ final boolean cancellable,
+ final boolean returnValue
+ ) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ // This cast is safe because T is expected to be the boxed type when used with primitives.
+ this.returnValue = (T) Boolean.valueOf(returnValue);
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with an initial primitive value.
+ *
+ * This constructor is provided for convenience to avoid boxing at the call site.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @param returnValue the initial return value
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor marks {@link #hasReturnValue()} as {@code true}.
+ */
+ @SuppressWarnings("unchecked")
+ public CallbackInfoReturnable(
+ @NotNull final String methodName,
+ final boolean cancellable,
+ final char returnValue
+ ) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ // This cast is safe because T is expected to be the boxed type when used with primitives.
+ this.returnValue = (T) Character.valueOf(returnValue);
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with an initial primitive value.
+ *
+ * This constructor is provided for convenience to avoid boxing at the call site.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @param returnValue the initial return value
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor marks {@link #hasReturnValue()} as {@code true}.
+ */
+ @SuppressWarnings("unchecked")
+ public CallbackInfoReturnable(
+ @NotNull final String methodName,
+ final boolean cancellable,
+ final byte returnValue
+ ) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ // This cast is safe because T is expected to be the boxed type when used with primitives.
+ this.returnValue = (T) Byte.valueOf(returnValue);
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Creates a new {@code CallbackInfoReturnable} with an initial primitive value.
+ *
+ * This constructor is provided for convenience to avoid boxing at the call site.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @param methodName the (simple) name of the intercepted method; never {@code null}
+ * @param cancellable whether this callback supports cancellation
+ * @param returnValue the initial return value
+ * @throws NullPointerException if {@code methodName} is {@code null} (e.g., plain javac, no IDEA instrumentation)
+ * @throws IllegalArgumentException if {@code methodName} is {@code null} and JetBrains @NotNull
+ * runtime instrumentation is active (IDEA compiler/bytecode instrumentation)
+ * @implNote Exception type depends on the toolchain: javac vs. IDEA-instrumented builds.
+ * @apiNote This constructor marks {@link #hasReturnValue()} as {@code true}.
+ */
+ @SuppressWarnings("unchecked")
+ public CallbackInfoReturnable(
+ @NotNull final String methodName,
+ final boolean cancellable,
+ final short returnValue
+ ) {
+ super(Objects.requireNonNull(methodName, "methodName"), cancellable);
+ // This cast is safe because T is expected to be the boxed type when used with primitives.
+ this.returnValue = (T) Short.valueOf(returnValue);
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Returns whether a return value has been explicitly set via {@link #setReturnValue(Object)} or via a constructor.
+ *
+ * This is distinct from {@code getReturnValue() != null} to allow distinguishing between
+ * "no value set" and "value set to null" for reference types.
+ * To avoid confusion: this flag only indicates whether a value has been set. It does not imply
+ * that the callback is cancelled. The weaver must check {@link #isCancelled()} separately to determine
+ * whether to use the return value as the effective return from the target method.
+ * Note that if you want to unset a previously set return value, you can call
+ * {@link #clearReturnValue()} to reset both the value and this flag.
+ *
+ * @return {@code true} if a return value has been set; {@code false} otherwise
+ * @see #clearReturnValue()
+ * @see #setReturnValue(Object)
+ */
+ public boolean hasReturnValue() {
+ return this.hasValueFlag;
+ }
+
+ /**
+ * Returns the current return value.
+ *
+ * If the callback is cancelled, the weaver will use this value as the effective
+ * return from the target method. If not cancelled, the value is advisory and
+ * may be ignored depending on the hook type.
+ *
+ * @return the current return value (may be {@code null} for reference types)
+ */
+ @Nullable
+ public T getReturnValue() {
+ return this.returnValue;
+ }
+
+ /**
+ * The following methods return the current return value cast to the respective primitive type.
+ *
+ * If the value is not of the expected type, a default value is returned instead
+ * (e.g., {@code 0} for numeric types, {@code false} for {@code boolean}, and {@code '\u0000'} for {@code char}).
+ * This avoids {@link ClassCastException} at the cost of potentially hiding type errors.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @return the current return value as the respective primitive type, or a default if not applicable
+ */
+ public int getReturnValueAsInt() {
+ if (this.returnValue instanceof Integer intValue) {
+ return intValue;
+ }
+
+ return 0;
+ }
+
+ /**
+ * The following methods return the current return value cast to the respective primitive type.
+ *
+ * If the value is not of the expected type, a default value is returned instead
+ * (e.g., {@code 0} for numeric types, {@code false} for {@code boolean}, and {@code '\u0000'} for {@code char}).
+ * This avoids {@link ClassCastException} at the cost of potentially hiding type errors.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @return the current return value as the respective primitive type, or a default if not applicable
+ */
+ public long getReturnValueAsLong() {
+ if (this.returnValue instanceof Long longValue) {
+ return longValue;
+ }
+
+ return 0L;
+ }
+
+ /**
+ * The following methods return the current return value cast to the respective primitive type.
+ *
+ * If the value is not of the expected type, a default value is returned instead
+ * (e.g., {@code 0} for numeric types, {@code false} for {@code boolean}, and {@code '\u0000'} for {@code char}).
+ * This avoids {@link ClassCastException} at the cost of potentially hiding type errors.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @return the current return value as the respective primitive type, or a default if not applicable
+ */
+ public float getReturnValueAsFloat() {
+ if (this.returnValue instanceof Float floatValue) {
+ return floatValue;
+ }
+
+ return 0f;
+ }
+
+ /**
+ * The following methods return the current return value cast to the respective primitive type.
+ *
+ * If the value is not of the expected type, a default value is returned instead
+ * (e.g., {@code 0} for numeric types, {@code false} for {@code boolean}, and {@code '\u0000'} for {@code char}).
+ * This avoids {@link ClassCastException} at the cost of potentially hiding type errors.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @return the current return value as the respective primitive type, or a default if not applicable
+ */
+ public double getReturnValueAsDouble() {
+ if (this.returnValue instanceof Double doubleValue) {
+ return doubleValue;
+ }
+
+ return 0d;
+ }
+
+ /**
+ * The following methods return the current return value cast to the respective primitive type.
+ *
+ * If the value is not of the expected type, a default value is returned instead
+ * (e.g., {@code 0} for numeric types, {@code false} for {@code boolean}, and {@code '\u0000'} for {@code char}).
+ * This avoids {@link ClassCastException} at the cost of potentially hiding type errors.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @return the current return value as the respective primitive type, or a default if not applicable
+ */
+ public boolean getReturnValueAsBoolean() {
+ if (this.returnValue instanceof Boolean booleanValue) {
+ return booleanValue;
+ }
+
+ return false;
+ }
+
+ /**
+ * The following methods return the current return value cast to the respective primitive type.
+ *
+ * If the value is not of the expected type, a default value is returned instead
+ * (e.g., {@code 0} for numeric types, {@code false} for {@code boolean}, and {@code '\u0000'} for {@code char}).
+ * This avoids {@link ClassCastException} at the cost of potentially hiding type errors.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @return the current return value as the respective primitive type, or a default if not applicable
+ */
+ public char getReturnValueAsChar() {
+ if (this.returnValue instanceof Character charValue) {
+ return charValue;
+ }
+
+ return '\u0000';
+ }
+
+ /**
+ * The following methods return the current return value cast to the respective primitive type.
+ *
+ * If the value is not of the expected type, a default value is returned instead
+ * (e.g., {@code 0} for numeric types, {@code false} for {@code boolean}, and {@code '\u0000'} for {@code char}).
+ * This avoids {@link ClassCastException} at the cost of potentially hiding type errors.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @return the current return value as the respective primitive type, or a default if not applicable
+ */
+ public byte getReturnValueAsByte() {
+ if (this.returnValue instanceof Byte byteValue) {
+ return byteValue;
+ }
+
+ return 0;
+ }
+
+ /**
+ * The following methods return the current return value cast to the respective primitive type.
+ *
+ * If the value is not of the expected type, a default value is returned instead
+ * (e.g., {@code 0} for numeric types, {@code false} for {@code boolean}, and {@code '\u0000'} for {@code char}).
+ * This avoids {@link ClassCastException} at the cost of potentially hiding type errors.
+ * The weaver is expected to handle boxing/unboxing as needed.
+ *
+ * @return the current return value as the respective primitive type, or a default if not applicable
+ */
+ public short getReturnValueAsShort() {
+ if (this.returnValue instanceof Short shortValue) {
+ return shortValue;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Sets a new return value.
+ *
+ * This updates the value returned by {@link #getReturnValue()} and marks
+ * {@link #hasReturnValue()} as {@code true}. If the callback is cancelled,
+ * the weaver will use this value as the effective return from the target method.
+ *
+ * @param value the new return value (may be {@code null} for reference types)
+ */
+ public void setReturnValue(@Nullable final T value) {
+ this.returnValue = value;
+ this.hasValueFlag = true;
+ }
+
+ /**
+ * Clears any previously set return value.
+ *
+ * This sets the value returned by {@link #getReturnValue()} to {@code null}
+ * and marks {@link #hasReturnValue()} as {@code false}.
+ */
+ public void clearReturnValue() {
+ this.returnValue = null;
+ this.hasValueFlag = false;
+ }
+
+ /**
+ * Returns the current return value if set; otherwise returns the provided default.
+ *
+ * This is a convenience method to avoid checking {@link #hasReturnValue()} manually.
+ *
+ * @param defaultValue the value to return if no return value has been set (may be {@code null} for reference types)
+ * @return the current return value if set; otherwise {@code defaultValue}
+ */
+ @Nullable
+ public T getOrDefault(final T defaultValue) {
+ return hasReturnValue() ? this.returnValue : defaultValue;
+ }
+
+ /**
+ * Convenience method that sets the return value and then attempts to cancel.
+ *
+ * Equivalent to calling {@link #setReturnValue(Object)} followed by {@link #cancel()}.
+ * If this callback is not cancellable, a {@link CancellationException} is thrown and
+ * the return value remains updated.
+ *
+ * @param value the new return value (may be {@code null} for reference types)
+ * @throws CancellationException if this callback is not cancellable
+ */
+ public void setReturnValueAndCancel(@Nullable final T value) throws CancellationException {
+ this.returnValue = value;
+ this.cancel();
+ }
+
+ /**
+ * Returns a concise debug representation including the current return value.
+ *
+ * The exact format is not part of the public API and may change between versions.
+ *
+ * @return a string representation for debugging purposes
+ */
+ @Override
+ public String toString() {
+ return "CallbackInfoReturnable{" +
+ "cancellable=" + isCancellable() +
+ ", cancelled=" + isCancelled() +
+ ", returnValue=" + this.returnValue +
+ '}';
+ }
+}
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/cancel/Cancellable.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/cancel/Cancellable.java
new file mode 100644
index 0000000..dd43ccf
--- /dev/null
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/cancel/Cancellable.java
@@ -0,0 +1,84 @@
+package de.splatgames.aether.mixins.core.api.cancel;
+
+import de.splatgames.aether.mixins.core.api.cancel.exception.CancellationException;
+
+/**
+ * Represents a standardized cancellation mechanism for callbacks and other
+ * weaver-related operations.
+ *
+ * This interface defines the core contract for objects that can be
+ * cancelled during runtime. It is typically implemented by
+ * {@link de.splatgames.aether.mixins.core.api.CallbackInfo CallbackInfo}
+ * and similar classes within the weaving process.
+ *
+ * Usage Example
+ *
+ * {@code
+ * public void onEvent(final Cancellable callback) {
+ * if (shouldCancel()) {
+ * callback.cancel(); // signals cancellation
+ * }
+ *
+ * if (callback.isCancelled()) {
+ * System.out.println("Execution stopped due to cancellation");
+ * }
+ * }
+ * }
+ *
+ *
+ * Thread-safety
+ * Implementations of this interface are generally not thread-safe unless
+ * explicitly documented otherwise. The cancellation state is typically
+ * manipulated within the same thread that invoked the callback.
+ *
+ * @author Erik Pförtner
+ * @implNote Implementations should clearly define whether the cancellation
+ * flag can be reset or is permanent once set to {@code true}.
+ * @since 0.2.0
+ */
+public interface Cancellable {
+
+ /**
+ * Returns whether this instance has been cancelled.
+ *
+ * A cancelled instance signals to the weaver or caller that further
+ * processing should be skipped. The exact effect of cancellation depends
+ * on the context in which it is used.
+ *
+ * @return {@code true} if this instance has been cancelled; {@code false} otherwise
+ */
+ boolean isCancelled();
+
+ /**
+ * Returns whether this instance supports cancellation.
+ *
+ * If this method returns {@code false}, invoking {@link #cancel()} will
+ * throw a {@link CancellationException}. This allows for callbacks that
+ * are purely informational and cannot stop the original execution.
+ *
+ * @return {@code true} if this instance can be cancelled; {@code false} otherwise
+ */
+ boolean isCancellable();
+
+ /**
+ * Attempts to cancel this instance.
+ *
+ * When successful, the cancellation state will be set to {@code true}
+ * and the weaver or caller will stop processing the original target method
+ * or operation.
+ *
+ * If this instance is not {@linkplain #isCancellable() cancellable},
+ * a {@link CancellationException} will be thrown instead.
+ *
+ *
+ * {@code
+ * if (callback.isCancellable()) {
+ * callback.cancel();
+ * }
+ * }
+ *
+ *
+ * @throws CancellationException if cancellation is not supported
+ */
+ void cancel() throws CancellationException;
+}
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/cancel/exception/CancellationException.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/cancel/exception/CancellationException.java
new file mode 100644
index 0000000..767ef0b
--- /dev/null
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/cancel/exception/CancellationException.java
@@ -0,0 +1,61 @@
+package de.splatgames.aether.mixins.core.api.cancel.exception;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.io.Serial;
+
+/**
+ * Exception thrown to indicate that a cancellation attempt was made
+ * on a non-cancellable object or callback.
+ *
+ * This exception is typically raised when code tries to invoke
+ * {@link de.splatgames.aether.mixins.core.api.cancel.Cancellable#cancel()}
+ * on an object that is not {@linkplain de.splatgames.aether.mixins.core.api.cancel.Cancellable#isCancellable() cancellable}.
+ *
+ * Typical Usage
+ * Thrown in scenarios where cancellation is requested but the target
+ * cannot support this behavior. Example:
+ *
+ *
+ * {@code
+ * public void handleCallback(final Cancellable callback) {
+ * try {
+ * callback.cancel();
+ * } catch (CancellationException ex) {
+ * System.err.println("Cancellation failed: " + ex.getMessage());
+ * }
+ * }
+ * }
+ *
+ *
+ * Design Considerations
+ *
+ * - The exception contains only a message, as cancellation errors are usually
+ * context-specific and do not require additional structured data.
+ *
+ *
+ * @author Erik Pförtner
+ * @implNote This exception is used exclusively by the Aether Mixins API
+ * to indicate a misuse of cancellation operations, typically at runtime
+ * during callback execution.
+ * @since 0.2.0
+ */
+public class CancellationException extends RuntimeException {
+
+ /**
+ * Serialization identifier for binary compatibility.
+ */
+ @Serial
+ private static final long serialVersionUID = 4669150090179254631L;
+
+ /**
+ * Constructs a new {@code CancellationException} with the specified detail message.
+ *
+ * The message may be {@code null} if no additional information is required.
+ *
+ * @param message the detail message, or {@code null} if not provided
+ */
+ public CancellationException(@Nullable final String message) {
+ super(message);
+ }
+}
From 8038c246cad4a0f11f8e22a293bd571fcc9c5006 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Fri, 12 Sep 2025 11:29:12 +0200
Subject: [PATCH 06/14] Add `cancellable` property to `@Inject` for runtime
method cancellation
- Introduce `cancellable` flag in `@Inject` to enable cancellation of target methods via `CallbackInfo` or `CallbackInfoReturnable`.
- Update `@Inject` documentation to reflect cancellation support.
---
.../mixins/core/api/CallbackInfoReturnable.java | 2 +-
.../de/splatgames/aether/mixins/core/api/Inject.java | 12 ++++++++++++
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfoReturnable.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfoReturnable.java
index 126dd2e..163b805 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfoReturnable.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfoReturnable.java
@@ -17,7 +17,7 @@
* Usage
*
* {@code
- * @Inject(method = "compute", at = @At("HEAD"), cancellable = true)
+ * @Inject(method = "compute(I)I", at = Inject.At.HEAD, cancellable = true)
* private static void onCompute(final int input, final CallbackInfoReturnable cir) {
* if (input < 0) {
* cir.setReturnValue(0);
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Inject.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Inject.java
index c4c4c78..3f6ea8f 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Inject.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Inject.java
@@ -141,6 +141,18 @@
*/
boolean remap() default true;
+ /**
+ * Cancellation support for this injection point.
+ *
+ * Callbacks marked {@code cancellable = true} may signal cancellation via a backend-defined mechanism
+ * (e.g., a {@link CallbackInfo} or {@link CallbackInfoReturnable} parameter).
+ * When a callback cancels, the original target method must abort
+ * its execution as soon as possible.
+ *
+ * @return {@code true} if the callback is allowed to cancel the target method; {@code false} otherwise
+ */
+ boolean cancellable() default false;
+
/**
* Well-known injection points.
*/
From d1e2a3a18d017feb156aa17b870eb8858d435d43 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Fri, 12 Sep 2025 14:28:53 +0200
Subject: [PATCH 07/14] Introduce `@Shadow` annotation and enhance `HookShape`
for flexible descriptor handling:
- Add `@Shadow` API for field and method remapping support.
- Extend `HookShape` utility to support `CallbackInfoReturnable`.
- Implement CIR-specific descriptor validation and argument handling.
- Add operand emission methods for CIR initialization, loading, and return manipulation.
---
.../bytecode/weaver/asm/util/HookShape.java | 355 ++++++++++++++++--
.../aether/mixins/core/api/Shadow.java | 17 +
.../refmap/AnnotationRefmapBuilder.java | 2 -
3 files changed, 337 insertions(+), 37 deletions(-)
create mode 100644 aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Shadow.java
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
index 54f10dc..8d62d51 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
@@ -34,6 +34,10 @@
* - {@link Kind#THIS_CI} – {@code (OWNER; CallbackInfo)V} (only for instance targets)
* - {@link Kind#ARGS_CI} – {@code (A B ...; CallbackInfo)V}
* - {@link Kind#THIS_ARGS_CI} – {@code (OWNER; A B ...; CallbackInfo)V} (only for instance targets)
+ * - {@link Kind#NONE_CIR} – {@code (CallbackInfoReturnable)V}
+ * - {@link Kind#THIS_CIR} – {@code (OWNER; CallbackInfoReturnable)V}
+ * - {@link Kind#ARGS_CIR} – {@code (A B ...; CallbackInfoReturnable)V}
+ * - {@link Kind#THIS_ARGS_CIR} – {@code (OWNER; A B ...; CallbackInfoReturnable)V}
*
*
* Constraints:
@@ -47,20 +51,33 @@
* {@code
* final boolean instance = HookShape.isInstance(targetAccess);
* final HookShape.Kind kind =
- * HookShape.match(instance, ownerInternal, targetDesc, hookDesc, "de/splatgames/.../CallbackInfo");
+ * HookShape.match(instance, ownerInternal, targetDesc, hookDesc, CI_INTERNAL, CIR_INTERNAL);
* if (kind == null) { * incompatible signature * }
*
* int local = instance ? 1 : 0;
* HookShape.emitThisIfNeeded(mv, kind);
+ *
* if (HookShape.passesArgs(kind)) {
- * local = HookShape.emitArgs(mv, targetDesc, local);
+ * local = HookShape.emitArgs(mv, targetDesc, local);
+ * }
+ *
+ * int cbLocal = -1;
+ * if (kind.usesCallbackInfo()) {
+ * cbLocal = newLocal(Type.getObjectType(CI_INTERNAL));
+ * HookShape.newCallbackInfoIfNeeded(mv, kind, CI_INTERNAL, cbLocal);
+ * HookShape.emitLoadCallbackInfoIfNeeded(mv, kind, cbLocal);
+ * } else if (kind.usesCallbackInfoReturnable()) {
+ * cbLocal = newLocal(Type.getObjectType(CIR_INTERNAL));
+ * HookShape.newCallbackInfoReturnableIfNeeded(mv, kind, CIR_INTERNAL, cbLocal);
+ * HookShape.emitLoadCallbackInfoReturnableIfNeeded(mv, kind, cbLocal);
* }
- * final int ciLocal = HookShape.newCallbackInfoIfNeeded(mv, kind, "de/splatgames/.../CallbackInfo", local);
- * HookShape.emitLoadCallbackInfoIfNeeded(mv, kind, ciLocal);
- * // then: mv.visitMethodInsn(INVOKESTATIC, hookOwner, hookName, hookDesc, false);
+ *
* }
*
* Thread-safety: This utility class is stateless and thread-safe.
+ *
+ * @author Erik Pförtner
+ * @since 0.2.0
*/
public final class HookShape {
@@ -112,16 +129,69 @@ public enum Kind {
/**
* Hook descriptor: {@code (OWNER; args..., CallbackInfo)V}. Requires instance targets.
*/
- THIS_ARGS_CI(true, true, true);
+ THIS_ARGS_CI(true, true, true),
+
+ /**
+ * Hook descriptor: {@code (CallbackInfoReturnable)V}.
+ */
+ NONE_CIR(false, false, false, true),
+
+ /**
+ * Hook descriptor: {@code (OWNER; CallbackInfoReturnable)V}. Requires instance targets.
+ */
+ THIS_CIR(true, false, false, true),
+
+ /**
+ * Hook descriptor: {@code (args..., CallbackInfoReturnable)V}.
+ */
+ ARGS_CIR(false, true, false, true),
+
+ /**
+ * Hook descriptor: {@code (OWNER; args..., CallbackInfoReturnable)V}. Requires instance targets.
+ */
+ THIS_ARGS_CIR(true, true, false, true);
+ /**
+ * Whether this shape requires loading {@code this} (local slot 0) before invoking the hook.
+ */
private final boolean requiresThis;
+ /**
+ * Whether this shape requires loading all target arguments (in declaration order) before invoking the hook.
+ */
private final boolean passesArgs;
+ /**
+ * Whether this shape includes a trailing {@code CallbackInfo} parameter.
+ */
private final boolean usesCallbackInfo;
+ /**
+ * Whether this shape includes a trailing {@code CallbackInfoReturnable} parameter.
+ */
+ private final boolean usesCallbackInfoReturnable;
+ /**
+ * Constructs a new shape with the given flags.
+ *
+ * @param requiresThis whether {@code this} must be loaded before invoking the hook
+ * @param passesArgs whether all target arguments must be loaded before invoking the hook
+ * @param usesCallbackInfo whether a trailing {@code CallbackInfo} parameter is present
+ */
Kind(final boolean requiresThis, final boolean passesArgs, final boolean usesCallbackInfo) {
+ this(requiresThis, passesArgs, usesCallbackInfo, false);
+ }
+
+ /**
+ * Constructs a new shape with the given flags.
+ *
+ * @param requiresThis whether {@code this} must be loaded before invoking the hook
+ * @param passesArgs whether all target arguments must be loaded before invoking the hook
+ * @param usesCallbackInfo whether a trailing {@code CallbackInfo} parameter is present
+ * @param usesCallbackInfoReturnable whether a trailing {@code CallbackInfoReturnable} parameter is present
+ */
+ Kind(final boolean requiresThis, final boolean passesArgs, final boolean usesCallbackInfo, final boolean usesCallbackInfoReturnable) {
this.requiresThis = requiresThis;
this.passesArgs = passesArgs;
this.usesCallbackInfo = usesCallbackInfo;
+ this.usesCallbackInfoReturnable = usesCallbackInfoReturnable;
}
/**
@@ -150,27 +220,35 @@ public boolean passesArgs() {
public boolean usesCallbackInfo() {
return this.usesCallbackInfo;
}
- }
- // --------------------------------------------------------------------------------------------
- // Descriptor matching
- // --------------------------------------------------------------------------------------------
+ /**
+ * Whether this shape includes a trailing {@code CallbackInfoReturnable} parameter.
+ *
+ * @return {@code true} if a {@code CallbackInfoReturnable} argument is present, otherwise {@code false}
+ */
+ public boolean usesCallbackInfoReturnable() {
+ return this.usesCallbackInfoReturnable;
+ }
+ }
/**
* Matches a hook descriptor against a target method and returns the {@link Kind}.
*
* Checks:
*
- * - Hook return type is {@code void}.
- * - Positional compatibility of optional {@code this}, target arguments, and optional trailing {@code CallbackInfo}.
- * - If {@code ciInternalName} is non-null, the last parameter must match that object type to be considered CI.
+ * - Hook return type must always be {@code void}.
+ * - Positional compatibility of optional {@code this}, target arguments, and optional trailing {@code CallbackInfo}/{@code CallbackInfoReturnable}.
+ * - {@code CallbackInfo} is only valid for target methods with a void return type.
+ * - {@code CallbackInfoReturnable} is only valid for target methods with a non-void return type.
+ * - Only one trailing callback parameter is allowed.
*
*
- * @param instance {@code true} if the target method is an instance method (not {@code ACC_STATIC})
- * @param ownerInternal internal JVM name of the target owner class (e.g., {@code com/example/Foo}); must not be {@code null}
- * @param targetDesc descriptor of the target method (e.g., {@code (I)Ljava/lang/String;}); must not be {@code null}
- * @param hookDesc descriptor of the hook method to validate; must not be {@code null}
- * @param ciInternalName internal JVM name of the {@code CallbackInfo} class; if {@code null}, CI is not considered
+ * @param instance {@code true} if the target method is an instance method (not {@code ACC_STATIC})
+ * @param ownerInternal internal JVM name of the target owner class (e.g., {@code com/example/Foo}); must not be {@code null}
+ * @param targetDesc descriptor of the target method (e.g., {@code (I)Ljava/lang/String;}); must not be {@code null}
+ * @param hookDesc descriptor of the hook method to validate; must not be {@code null}
+ * @param ciInternalName internal JVM name of the {@code CallbackInfo} class; may be {@code null} if CI is not supported
+ * @param cirInternalName internal JVM name of the {@code CallbackInfoReturnable} class; may be {@code null} if CIR is not supported
* @return the matched {@link Kind}, or {@code null} if incompatible
*/
@Nullable
@@ -178,17 +256,27 @@ public static Kind match(final boolean instance,
@NotNull final String ownerInternal,
@NotNull final String targetDesc,
@NotNull final String hookDesc,
- @Nullable final String ciInternalName) {
- final Type[] tArgs = Type.getArgumentTypes(targetDesc);
- final Type[] hArgs = Type.getArgumentTypes(hookDesc);
- final Type hRet = Type.getReturnType(hookDesc);
- if (!Type.VOID_TYPE.equals(hRet)) {
+ @Nullable final String ciInternalName,
+ @Nullable final String cirInternalName) {
+
+ final Type[] tArgs = Type.getArgumentTypes(targetDesc); // Target arguments
+ final Type[] hArgs = Type.getArgumentTypes(hookDesc); // Hook arguments
+ final Type targetRet = Type.getReturnType(targetDesc); // Target return type
+ final Type hookRet = Type.getReturnType(hookDesc); // Hook return type
+
+ // Hook must always return void
+ if (!Type.VOID_TYPE.equals(hookRet)) {
return null;
}
+ final boolean targetIsVoid = Type.VOID_TYPE.equals(targetRet);
final int hLen = hArgs.length;
final Type ownerT = Type.getObjectType(ownerInternal);
+ // -------------------------------------
+ // Basic forms without CI or CIR
+ // -------------------------------------
+
// ()V
if (hLen == 0) {
return Kind.NONE;
@@ -214,8 +302,10 @@ public static Kind match(final boolean instance,
}
}
- // With trailing CallbackInfo
- if (ciInternalName != null && hLen >= 1 && isLastCallbackInfo(hArgs, ciInternalName)) {
+ // -------------------------------------
+ // CI Handling (void target only)
+ // -------------------------------------
+ if (ciInternalName != null && targetIsVoid && isLastObjectType(hArgs, ciInternalName)) {
final int coreLen = hLen - 1;
// (CallbackInfo)V
@@ -244,9 +334,43 @@ public static Kind match(final boolean instance,
}
}
+ // -------------------------------------
+ // CIR Handling (non-void target only)
+ // -------------------------------------
+ if (cirInternalName != null && !targetIsVoid && isLastObjectType(hArgs, cirInternalName)) {
+ final int coreLen = hLen - 1;
+
+ // (CallbackInfoReturnable)V
+ if (coreLen == 0) {
+ return Kind.NONE_CIR;
+ }
+
+ // (OWNER; CallbackInfoReturnable)V
+ if (coreLen == 1 && hArgs[0].equals(ownerT)) {
+ return instance ? Kind.THIS_CIR : null;
+ }
+
+ // (args..., CallbackInfoReturnable)V
+ if (coreLen == tArgs.length && allEqual(hArgs, 0, tArgs, 0, tArgs.length)) {
+ return Kind.ARGS_CIR;
+ }
+
+ // (OWNER; args..., CallbackInfoReturnable)V
+ if (coreLen == tArgs.length + 1 && hArgs[0].equals(ownerT)) {
+ if (!instance) {
+ return null;
+ }
+ if (allEqual(hArgs, 1, tArgs, 0, tArgs.length)) {
+ return Kind.THIS_ARGS_CIR;
+ }
+ }
+ }
+
+ // No valid match
return null;
}
+
/**
* Compares two slices of {@link Type} arrays for equality.
*
@@ -271,21 +395,17 @@ private static boolean allEqual(@NotNull final Type[] a,
}
/**
- * Checks whether the last parameter type in {@code hArgs} is an object whose internal name equals {@code ciInternalName}.
+ * Determines whether the last element of the given {@link Type} array is an object type with the given internal name.
*
- * @param hArgs hook argument types; must not be {@code null}
- * @param ciInternalName internal JVM name of {@code CallbackInfo}; must not be {@code null}
- * @return {@code true} if the last parameter matches the CI type; otherwise {@code false}
+ * @param hArgs array of types; must not be {@code null} and must have at least one element
+ * @param internalName expected internal name of the last element (e.g., {@code de/splatgames/.../CallbackInfo}); must not be {@code null}
+ * @return {@code true} if the last element is an object type with the given internal name; otherwise {@code false}
*/
- private static boolean isLastCallbackInfo(@NotNull final Type[] hArgs,
- @NotNull final String ciInternalName) {
+ private static boolean isLastObjectType(@NotNull final Type[] hArgs, @NotNull final String internalName) {
final Type last = hArgs[hArgs.length - 1];
- return last.getSort() == Type.OBJECT && ciInternalName.equals(last.getInternalName());
+ return last.getSort() == Type.OBJECT && internalName.equals(last.getInternalName());
}
- // --------------------------------------------------------------------------------------------
- // Operand emission helpers
- // --------------------------------------------------------------------------------------------
/**
* Determines whether the target access flags denote an instance method.
@@ -365,6 +485,107 @@ public static int newCallbackInfoIfNeeded(@NotNull final MethodVisitor mv,
return ciLocal;
}
+ /**
+ * Stores the return value (top of stack) into the given local slot, using the appropriate store instruction
+ * for the return type of the target method.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param targetDesc descriptor of the target method; must not be {@code null}
+ * @param retLocal local slot index to store the return value (must be a free local)
+ * @return {@code retLocal} for convenience
+ * @throws IllegalArgumentException if the return type is unsupported
+ */
+ public static int storeReturnValueBeforeTail(@NotNull final MethodVisitor mv, @NotNull final String targetDesc, final int retLocal) {
+ final Type ret = Type.getReturnType(targetDesc);
+ switch (ret.getSort()) {
+ case Type.BOOLEAN, Type.BYTE, Type.SHORT, Type.CHAR, Type.INT -> mv.visitVarInsn(Opcodes.ISTORE, retLocal);
+ case Type.FLOAT -> mv.visitVarInsn(Opcodes.FSTORE, retLocal);
+ case Type.LONG -> mv.visitVarInsn(Opcodes.LSTORE, retLocal);
+ case Type.DOUBLE -> mv.visitVarInsn(Opcodes.DSTORE, retLocal);
+ case Type.ARRAY, Type.OBJECT -> mv.visitVarInsn(Opcodes.ASTORE, retLocal);
+ default -> throw new IllegalArgumentException("Unsupported return type: " + ret);
+ }
+ return retLocal;
+ }
+
+ /**
+ * Loads the return value from the given local slot onto the stack, using the appropriate load instruction
+ * for the return type of the target method.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param targetDesc descriptor of the target method; must not be {@code null}
+ * @param retLocal local slot index where the return value was previously stored
+ * @throws IllegalArgumentException if the return type is unsupported
+ */
+ public static void loadReturnValueFromLocal(@NotNull final MethodVisitor mv, @NotNull final String targetDesc, final int retLocal) {
+ final Type ret = Type.getReturnType(targetDesc);
+ switch (ret.getSort()) {
+ case Type.BOOLEAN, Type.BYTE, Type.SHORT, Type.CHAR, Type.INT -> mv.visitVarInsn(Opcodes.ILOAD, retLocal);
+ case Type.FLOAT -> mv.visitVarInsn(Opcodes.FLOAD, retLocal);
+ case Type.LONG -> mv.visitVarInsn(Opcodes.LLOAD, retLocal);
+ case Type.DOUBLE -> mv.visitVarInsn(Opcodes.DLOAD, retLocal);
+ case Type.ARRAY, Type.OBJECT -> mv.visitVarInsn(Opcodes.ALOAD, retLocal);
+ default -> throw new IllegalArgumentException("Unsupported return type: " + ret);
+ }
+ }
+
+ /**
+ * Emits the appropriate return instruction for the given return type.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param ret the return type of the target method; must not be {@code null}
+ * @throws IllegalArgumentException if the return type is unsupported
+ */
+ public static void emitReturnFor(@NotNull final MethodVisitor mv, @NotNull final Type ret) {
+ switch (ret.getSort()) {
+ case Type.VOID -> mv.visitInsn(Opcodes.RETURN);
+ case Type.BOOLEAN, Type.BYTE, Type.SHORT, Type.CHAR, Type.INT -> mv.visitInsn(Opcodes.IRETURN);
+ case Type.FLOAT -> mv.visitInsn(Opcodes.FRETURN);
+ case Type.LONG -> mv.visitInsn(Opcodes.LRETURN);
+ case Type.DOUBLE -> mv.visitInsn(Opcodes.DRETURN);
+ case Type.ARRAY, Type.OBJECT -> mv.visitInsn(Opcodes.ARETURN);
+ default -> throw new IllegalArgumentException("Unsupported return type for return opcode: " + ret);
+ }
+ }
+
+ /**
+ * Allocates and initializes a new {@code CallbackInfoReturnable} instance and stores it in local slot {@code cirLocal}
+ * if the given {@link Kind} uses {@code CallbackInfoReturnable}. Otherwise, does nothing and returns {@code -1}.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param kind matched hook shape; must not be {@code null}
+ * @param cirInternalName internal JVM name of {@code CallbackInfoReturnable}; must not be {@code null} if {@code kind} uses CIR
+ * @param cirLocal local slot index to store the new instance (must be a free local)
+ * @return {@code cirLocal} if a new instance was created; {@code -1} if the shape does not use {@code CallbackInfoReturnable}
+ * @throws IllegalArgumentException if the shape uses CIR but {@code cirInternalName} is {@code null}
+ */
+ public static int newCallbackInfoReturnableIfNeeded(@NotNull final MethodVisitor mv, @NotNull final Kind kind, @Nullable final String cirInternalName, final int cirLocal) {
+ if (!kind.usesCallbackInfoReturnable()) {
+ return -1;
+ }
+ if (cirInternalName == null) {
+ throw new IllegalArgumentException("CIR internal name required: " + kind);
+ }
+ mv.visitTypeInsn(Opcodes.NEW, cirInternalName);
+ mv.visitInsn(Opcodes.DUP);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, cirInternalName, "", "()V", false);
+ mv.visitVarInsn(Opcodes.ASTORE, cirLocal);
+ return cirLocal;
+ }
+
+ /**
+ * Emits {@code ALOAD cirLocal} if the given {@link Kind} includes a trailing {@code CallbackInfoReturnable} parameter.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param kind matched hook shape; must not be {@code null}
+ * @param cirLocal local slot where a previously created {@code CallbackInfoReturnable} instance is stored
+ */
+ public static void emitLoadCallbackInfoReturnableIfNeeded(@NotNull final MethodVisitor mv, @NotNull final Kind kind, final int cirLocal) {
+ if (kind.usesCallbackInfoReturnable()) {
+ mv.visitVarInsn(Opcodes.ALOAD, cirLocal);
+ }
+ }
+
/**
* Emits {@code ALOAD ciLocal} if the given {@link Kind} includes a trailing {@code CallbackInfo} parameter.
*
@@ -380,6 +601,60 @@ public static void emitLoadCallbackInfoIfNeeded(@NotNull final MethodVisitor mv,
}
}
+ /**
+ * Constructs the descriptor for {@code CallbackInfo.getReturn()} or {@code CallbackInfoReturnable.getReturn()}.
+ *
+ * @param ret the return type of the target method; must not be {@code null}
+ * @return the descriptor string (e.g., {@code ()I} for {@code int})
+ */
+ @NotNull
+ public static String cirGetterDescFor(@NotNull final Type ret) {
+ return "()" + ret.getDescriptor();
+ }
+
+ /**
+ * Constructs the descriptor for {@code CallbackInfoReturnable.setReturn(R)}.
+ *
+ * @param ret the return type of the target method; must not be {@code null}
+ * @return the descriptor string (e.g., {@code (I)V} for {@code int})
+ */
+ @NotNull
+ public static String cirSetterDescFor(@NotNull final Type ret) {
+ return "(" + ret.getDescriptor() + ")V";
+ }
+
+ /**
+ * Emits a call to {@code CallbackInfoReturnable.getReturn()}.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param cirInternal internal JVM name of {@code CallbackInfoReturnable}; must not be {@code null}
+ * @param ret the return type of the target method; must not be {@code null}
+ */
+ public static void emitCirGetReturn(@NotNull final MethodVisitor mv, @NotNull final String cirInternal, @NotNull final Type ret) {
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturn", cirGetterDescFor(ret), false);
+ }
+
+ /**
+ * Emits a call to {@code CallbackInfoReturnable.setReturn(R)}.
+ *
+ * @param mv downstream method visitor; must not be {@code null}
+ * @param cirInternal internal JVM name of {@code CallbackInfoReturnable}; must not be {@code null}
+ * @param ret the return type of the target method; must not be {@code null}
+ */
+ public static void emitCirSetReturn(@NotNull final MethodVisitor mv, @NotNull final String cirInternal, @NotNull final Type ret) {
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturn", cirSetterDescFor(ret), false);
+ }
+
+ /**
+ * Convenience wrapper for {@link Kind#usesCallbackInfo()} and {@link Kind#usesCallbackInfoReturnable()}.
+ *
+ * @param kind matched hook shape; must not be {@code null}
+ * @return {@code true} if the shape uses either form of callback info; otherwise {@code false}
+ */
+ public static boolean usesAnyCallback(@NotNull final Kind kind) {
+ return kind.usesCallbackInfo() || kind.usesCallbackInfoReturnable();
+ }
+
/**
* Convenience wrapper for {@link Kind#usesCallbackInfo()}.
*
@@ -409,4 +684,14 @@ public static boolean requiresThis(@NotNull final Kind kind) {
public static boolean passesArgs(@NotNull final Kind kind) {
return kind.passesArgs();
}
+
+ /**
+ * Determines if the return type of the given method descriptor is void.
+ *
+ * @param targetDesc the method descriptor to be analyzed, must not be null
+ * @return true if the return type of the method descriptor is void, false otherwise
+ */
+ public static boolean isVoid(@NotNull final String targetDesc) {
+ return Type.VOID_TYPE.equals(Type.getReturnType(targetDesc));
+ }
}
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Shadow.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Shadow.java
new file mode 100644
index 0000000..01c549e
--- /dev/null
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Shadow.java
@@ -0,0 +1,17 @@
+package de.splatgames.aether.mixins.core.api;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+@Documented
+@Retention(RUNTIME)
+@Target({METHOD, FIELD})
+public @interface Shadow {
+
+ boolean remap() default true;
+}
diff --git a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/config/refmap/AnnotationRefmapBuilder.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/config/refmap/AnnotationRefmapBuilder.java
index a2e94ef..9b0eac4 100644
--- a/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/config/refmap/AnnotationRefmapBuilder.java
+++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/config/refmap/AnnotationRefmapBuilder.java
@@ -168,8 +168,6 @@ private static List dedupStrings(@NotNull final List in) {
return List.copyOf(out);
}
- // -------- Annotation element helpers (handle presence/absence of optional elements) --------
-
/**
* Returns the {@code id} element from {@link Inject} or {@code null} if the element is not present in the annotation type.
*/
From 862777933f78ade093ed55f98d4ba75b2c7eb48f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Fri, 12 Sep 2025 14:42:49 +0200
Subject: [PATCH 08/14] Remove outdated comment in `InjectHeadAdapter` to
improve code clarity.
---
.../mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java | 1 -
1 file changed, 1 deletion(-)
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
index 43399d2..5b95ae3 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
@@ -239,7 +239,6 @@ public void visitMethodInsn(final int opcode,
return; // only once
}
if (opcode == Opcodes.INVOKESPECIAL && "".equals(name)) {
- // jetzt direkt NACH dem ctor-call injizieren (gleiche Logik wie bisher in visitCode())
final boolean instance = HookShape.isInstance(this.targetAccess);
final @Nullable HookShape.Kind kind = HookShape.match(
instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL
From bc37ecb590d60ff52562c99c7552e11a1aba872f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Mon, 15 Sep 2025 11:05:04 +0200
Subject: [PATCH 09/14] Extend `InjectHeadAdapter` to support
`CallbackInfoReturnable`:
- Add `CIR_INTERNAL` for handling `CallbackInfoReturnable`.
- Update hook shape matching to include CIR support.
- Implement descriptor validation logic for CIR usage.
- Introduce CIR initialization, operand marshalling, and return handling mechanics.
---
.../weaver/asm/adapter/InjectHeadAdapter.java | 61 ++++++++++++++-----
1 file changed, 47 insertions(+), 14 deletions(-)
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
index 5b95ae3..568c213 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
@@ -44,6 +44,11 @@ public final class InjectHeadAdapter extends LocalVariablesSorter {
*/
private static final String CI_INTERNAL = "de/splatgames/aether/mixins/core/api/CallbackInfo";
+ /**
+ * Internal JVM class name of {@link de.splatgames.aether.mixins.core.api.CallbackInfoReturnable CallbackInfoReturnable}.
+ */
+ private static final String CIR_INTERNAL = "de/splatgames/aether/mixins/core/api/CallbackInfoReturnable";
+
/**
* The resolved hook (owner/name/desc) to invoke at method entry.
*/
@@ -171,28 +176,38 @@ public void visitCode() {
return;
}
final boolean instance = HookShape.isInstance(this.targetAccess);
- @Nullable final HookShape.Kind kind = HookShape.match(instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL);
+ @Nullable final HookShape.Kind kind = HookShape.match(
+ instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL, CIR_INTERNAL
+ );
if (kind == null) {
if (!this.optional) {
- throw new IllegalStateException("HEAD inject: incompatible hook signature for id=" + this.id + " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc());
+ throw new IllegalStateException("HEAD inject: incompatible hook signature for id=" + this.id +
+ " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc());
}
- // Optional skip.
- return;
+ return; // optional skip
}
final Type targetRet = Type.getReturnType(this.targetDesc);
final boolean targetIsVoid = Type.VOID_TYPE.equals(targetRet);
- final boolean usesCI = HookShape.usesCallbackInfo(kind);
+ final boolean usesCI = kind.usesCallbackInfo();
+ final boolean usesCIR = kind.usesCallbackInfoReturnable();
+ // Enforce CI for void, CIR for non-void
if (usesCI && !targetIsVoid) {
if (!this.optional) {
throw new IllegalStateException("HEAD inject: CallbackInfo requires void target (id=" + this.id + ").");
}
- // Optional skip.
+ return;
+ }
+ if (usesCIR && targetIsVoid) {
+ if (!this.optional) {
+ throw new IllegalStateException("HEAD inject: CallbackInfoReturnable requires non-void target (id=" + this.id + ").");
+ }
return;
}
+ // Marshal operands
if (HookShape.requiresThis(kind)) {
HookShape.emitThisIfNeeded(this.mv, kind);
}
@@ -201,27 +216,45 @@ public void visitCode() {
local = HookShape.emitArgs(this.mv, this.targetDesc, local);
}
- int ciLocal = -1;
+ // Create and load CI/CIR if needed
+ int cbLocal = -1;
if (usesCI) {
- ciLocal = newLocal(Type.getObjectType(CI_INTERNAL));
- HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, ciLocal);
- HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, ciLocal);
+ cbLocal = newLocal(Type.getObjectType(CI_INTERNAL));
+ HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, cbLocal);
+ HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, cbLocal);
+ } else if (usesCIR) {
+ cbLocal = newLocal(Type.getObjectType(CIR_INTERNAL));
+ HookShape.newCallbackInfoReturnableIfNeeded(this.mv, kind, CIR_INTERNAL, cbLocal);
+ HookShape.emitLoadCallbackInfoReturnableIfNeeded(this.mv, kind, cbLocal);
}
+ // Call hook
super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
this.markChanged.run();
this.applied = true;
+ // Early return branches
if (usesCI) {
// if (ci.isCancelled()) return;
- super.visitVarInsn(Opcodes.ALOAD, ciLocal);
+ super.visitVarInsn(Opcodes.ALOAD, cbLocal);
super.visitMethodInsn(Opcodes.INVOKEVIRTUAL, CI_INTERNAL, "isCancelled", "()Z", false);
final Label Lskip = new Label();
super.visitJumpInsn(Opcodes.IFEQ, Lskip);
super.visitInsn(Opcodes.RETURN);
super.visitLabel(Lskip);
+ } else if (usesCIR) {
+ // if (cir.isCancelled()) return cir.getReturn();
+ super.visitVarInsn(Opcodes.ALOAD, cbLocal);
+ super.visitMethodInsn(Opcodes.INVOKEVIRTUAL, CIR_INTERNAL, "isCancelled", "()Z", false);
+ final Label LskipCir = new Label();
+ super.visitJumpInsn(Opcodes.IFEQ, LskipCir);
+
+ super.visitVarInsn(Opcodes.ALOAD, cbLocal);
+ HookShape.emitCirGetReturn(this.mv, CIR_INTERNAL, targetRet);
+ HookShape.emitReturnFor(this.mv, targetRet);
+
+ super.visitLabel(LskipCir);
}
-
}
@Override
@@ -240,8 +273,8 @@ public void visitMethodInsn(final int opcode,
}
if (opcode == Opcodes.INVOKESPECIAL && "".equals(name)) {
final boolean instance = HookShape.isInstance(this.targetAccess);
- final @Nullable HookShape.Kind kind = HookShape.match(
- instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL
+ @Nullable final HookShape.Kind kind = HookShape.match(
+ instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL, CIR_INTERNAL
);
if (kind == null) {
if (!this.optional) {
From 02b73f15bdf2cc0fcf2019d69b45eba0bb54b191 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Mon, 15 Sep 2025 11:11:23 +0200
Subject: [PATCH 10/14] Extend `InjectTailAdapter` to support
`CallbackInfoReturnable`:
- Add `CIR_INTERNAL` for handling `CallbackInfoReturnable`.
- Update hook shape matching to include CIR support.
- Implement descriptor validation logic for CIR usage in tail injections.
- Introduce CIR initialization, operand marshalling, and return handling mechanics.
---
.../weaver/asm/adapter/InjectTailAdapter.java | 107 ++++++++++++++----
1 file changed, 85 insertions(+), 22 deletions(-)
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
index 12d8676..d1abe92 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
@@ -9,6 +9,7 @@
import org.objectweb.asm.Type;
import org.objectweb.asm.commons.LocalVariablesSorter;
+import static org.objectweb.asm.Opcodes.ALOAD;
import static org.objectweb.asm.Opcodes.ARETURN;
import static org.objectweb.asm.Opcodes.DRETURN;
import static org.objectweb.asm.Opcodes.FRETURN;
@@ -50,6 +51,11 @@ public final class InjectTailAdapter extends LocalVariablesSorter {
*/
private static final String CI_INTERNAL = "de/splatgames/aether/mixins/core/api/CallbackInfo";
+ /**
+ * Internal JVM class name of {@link de.splatgames.aether.mixins.core.api.CallbackInfoReturnable CallbackInfoReturnable}.
+ */
+ private static final String CIR_INTERNAL = "de/splatgames/aether/mixins/core/api/CallbackInfoReturnable";
+
/**
* The resolved hook (owner/name/desc) to invoke before each return.
*/
@@ -172,40 +178,97 @@ public void visitInsn(final int opcode) {
if (isReturn(opcode)) {
final boolean instance = HookShape.isInstance(this.targetAccess);
- final @Nullable HookShape.Kind kind = HookShape.match(
- instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL
+ @Nullable final HookShape.Kind kind = HookShape.match(
+ instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL, CIR_INTERNAL
);
- if (kind != null) {
- if (HookShape.requiresThis(kind)) {
- HookShape.emitThisIfNeeded(this.mv, kind);
+ if (kind == null) {
+ if (!this.optional) {
+ throw new IllegalStateException(
+ "TAIL inject: incompatible hook signature for id=" + this.id +
+ " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc()
+ );
}
- int local = instance ? 1 : 0;
- if (HookShape.passesArgs(kind)) {
- local = HookShape.emitArgs(this.mv, this.targetDesc, local);
+ super.visitInsn(opcode);
+ return;
+ }
+
+ final Type ret = Type.getReturnType(this.targetDesc);
+ final boolean isVoid = Type.VOID_TYPE.equals(ret);
+ final boolean usesCI = kind.usesCallbackInfo();
+ final boolean usesCIR = kind.usesCallbackInfoReturnable();
+
+ if (usesCI && !isVoid) {
+ if (!this.optional) {
+ throw new IllegalStateException(
+ "TAIL inject: CallbackInfo requires void target (id=" + this.id + ")."
+ );
}
+ super.visitInsn(opcode);
+ return;
+ }
- final boolean usesCI = HookShape.usesCallbackInfo(kind);
- int ciLocal;
- if (usesCI) {
- ciLocal = newLocal(Type.getObjectType(CI_INTERNAL));
- HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, ciLocal);
- HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, ciLocal);
+ if (usesCIR && isVoid) {
+ if (!this.optional) {
+ throw new IllegalStateException(
+ "TAIL inject: CallbackInfoReturnable requires non-void target (id=" + this.id + ")."
+ );
}
+ super.visitInsn(opcode);
+ return;
+ }
+
+ int retLocal = -1;
+ if (!isVoid) {
+ retLocal = newLocal(ret);
+ HookShape.storeReturnValueBeforeTail(this.mv, this.targetDesc, retLocal);
+ }
+
+ if (HookShape.requiresThis(kind)) {
+ HookShape.emitThisIfNeeded(this.mv, kind);
+ }
+ int local = instance ? 1 : 0;
+ if (HookShape.passesArgs(kind)) {
+ local = HookShape.emitArgs(this.mv, this.targetDesc, local);
+ }
+
+ int cbLocal = -1;
+ if (usesCI) {
+ cbLocal = newLocal(Type.getObjectType(CI_INTERNAL));
+ HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, cbLocal);
+ HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, cbLocal);
+ } else if (usesCIR) {
+ cbLocal = newLocal(Type.getObjectType(CIR_INTERNAL));
+ HookShape.newCallbackInfoReturnableIfNeeded(this.mv, kind, CIR_INTERNAL, cbLocal);
- super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
- this.markChanged.run();
- this.applied = true;
- } else if (!this.optional) {
- throw new IllegalStateException(
- "TAIL inject: incompatible hook signature for id=" + this.id +
- " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc()
- );
+ this.mv.visitVarInsn(ALOAD, cbLocal);
+ HookShape.loadReturnValueFromLocal(this.mv, this.targetDesc, retLocal);
+ HookShape.emitCirSetReturn(this.mv, CIR_INTERNAL, ret);
+
+ HookShape.emitLoadCallbackInfoReturnableIfNeeded(this.mv, kind, cbLocal);
}
+
+ super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
+ this.markChanged.run();
+ this.applied = true;
+
+ if (!isVoid) {
+ if (usesCIR) {
+ this.mv.visitVarInsn(ALOAD, cbLocal);
+ HookShape.emitCirGetReturn(this.mv, CIR_INTERNAL, ret);
+ } else {
+ HookShape.loadReturnValueFromLocal(this.mv, this.targetDesc, retLocal);
+ }
+ }
+
+ super.visitInsn(opcode);
+ return;
}
+
super.visitInsn(opcode);
}
+
/**
* Verifies that at least one return site was instrumented, unless the injection is marked optional.
*
From f1215b695e3426a8ee71a603ebe5e2bdcd049fba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Mon, 15 Sep 2025 13:16:55 +0200
Subject: [PATCH 11/14] Refactor `InjectHeadAdapter`, `InjectTailAdapter`, and
`HookShape` for enhanced callback handling:
- Replace `MethodVisitor` with `InjectHeadAdapter`/`InjectTailAdapter` instances in `HookShape` methods.
- Add method name and cancellable flag parameters to CallbackInfo creation logic for improved debugging.
- Extend operand marshaling and return handling mechanics to support detailed CallbackInfo operations.
- Introduce specialized methods for handling primitive return types in `CallbackInfoReturnable`.
---
.../weaver/asm/adapter/InjectHeadAdapter.java | 30 +++--
.../weaver/asm/adapter/InjectTailAdapter.java | 26 ++--
.../bytecode/weaver/asm/util/HookShape.java | 111 ++++++++++++++++--
3 files changed, 130 insertions(+), 37 deletions(-)
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
index 568c213..68e7570 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectHeadAdapter.java
@@ -209,23 +209,23 @@ public void visitCode() {
// Marshal operands
if (HookShape.requiresThis(kind)) {
- HookShape.emitThisIfNeeded(this.mv, kind);
+ HookShape.emitThisIfNeeded(this, kind);
}
int local = instance ? 1 : 0;
if (HookShape.passesArgs(kind)) {
- local = HookShape.emitArgs(this.mv, this.targetDesc, local);
+ local = HookShape.emitArgs(this, this.targetDesc, local);
}
// Create and load CI/CIR if needed
int cbLocal = -1;
if (usesCI) {
cbLocal = newLocal(Type.getObjectType(CI_INTERNAL));
- HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, cbLocal);
- HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, cbLocal);
+ HookShape.newCallbackInfoIfNeeded(this, kind, CI_INTERNAL, cbLocal, this.methodName, true);
+ HookShape.emitLoadCallbackInfoIfNeeded(this, kind, cbLocal);
} else if (usesCIR) {
cbLocal = newLocal(Type.getObjectType(CIR_INTERNAL));
- HookShape.newCallbackInfoReturnableIfNeeded(this.mv, kind, CIR_INTERNAL, cbLocal);
- HookShape.emitLoadCallbackInfoReturnableIfNeeded(this.mv, kind, cbLocal);
+ HookShape.newCallbackInfoReturnableIfNeeded(this, kind, CIR_INTERNAL, cbLocal, this.methodName, true);
+ HookShape.emitLoadCallbackInfoReturnableIfNeeded(this, kind, cbLocal);
}
// Call hook
@@ -250,8 +250,8 @@ public void visitCode() {
super.visitJumpInsn(Opcodes.IFEQ, LskipCir);
super.visitVarInsn(Opcodes.ALOAD, cbLocal);
- HookShape.emitCirGetReturn(this.mv, CIR_INTERNAL, targetRet);
- HookShape.emitReturnFor(this.mv, targetRet);
+ HookShape.emitCirGetReturn(this, CIR_INTERNAL, targetRet);
+ HookShape.emitReturnFor(this, targetRet);
super.visitLabel(LskipCir);
}
@@ -271,6 +271,12 @@ public void visitMethodInsn(final int opcode,
if (this.injectedAfterCtor) {
return; // only once
}
+
+ if (owner.equals("de/splatgames/aether/mixins/core/api/CallbackInfo")
+ || owner.equals("de/splatgames/aether/mixins/core/api/CallbackInfoReturnable")) {
+ return;
+ }
+
if (opcode == Opcodes.INVOKESPECIAL && "".equals(name)) {
final boolean instance = HookShape.isInstance(this.targetAccess);
@Nullable final HookShape.Kind kind = HookShape.match(
@@ -301,18 +307,18 @@ public void visitMethodInsn(final int opcode,
}
if (HookShape.requiresThis(kind)) {
- HookShape.emitThisIfNeeded(this.mv, kind);
+ HookShape.emitThisIfNeeded(this, kind);
}
int local = 1; // constructor is always instance
if (HookShape.passesArgs(kind)) {
- local = HookShape.emitArgs(this.mv, this.targetDesc, local);
+ local = HookShape.emitArgs(this, this.targetDesc, local);
}
int ciLocal = -1;
if (usesCI) {
ciLocal = newLocal(Type.getObjectType(CI_INTERNAL));
- HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, ciLocal);
- HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, ciLocal);
+ HookShape.newCallbackInfoIfNeeded(this, kind, CI_INTERNAL, ciLocal, this.methodName, true);
+ HookShape.emitLoadCallbackInfoIfNeeded(this, kind, ciLocal);
}
super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
index d1abe92..d711c2e 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/adapter/InjectTailAdapter.java
@@ -221,31 +221,31 @@ public void visitInsn(final int opcode) {
int retLocal = -1;
if (!isVoid) {
retLocal = newLocal(ret);
- HookShape.storeReturnValueBeforeTail(this.mv, this.targetDesc, retLocal);
+ HookShape.storeReturnValueBeforeTail(this, this.targetDesc, retLocal);
}
if (HookShape.requiresThis(kind)) {
- HookShape.emitThisIfNeeded(this.mv, kind);
+ HookShape.emitThisIfNeeded(this, kind);
}
int local = instance ? 1 : 0;
if (HookShape.passesArgs(kind)) {
- local = HookShape.emitArgs(this.mv, this.targetDesc, local);
+ local = HookShape.emitArgs(this, this.targetDesc, local);
}
int cbLocal = -1;
if (usesCI) {
cbLocal = newLocal(Type.getObjectType(CI_INTERNAL));
- HookShape.newCallbackInfoIfNeeded(this.mv, kind, CI_INTERNAL, cbLocal);
- HookShape.emitLoadCallbackInfoIfNeeded(this.mv, kind, cbLocal);
+ HookShape.newCallbackInfoIfNeeded(this, kind, CI_INTERNAL, cbLocal, /*method*/ "tail:" + this.id, /*cancellable*/ false);
+ HookShape.emitLoadCallbackInfoIfNeeded(this, kind, cbLocal);
} else if (usesCIR) {
cbLocal = newLocal(Type.getObjectType(CIR_INTERNAL));
- HookShape.newCallbackInfoReturnableIfNeeded(this.mv, kind, CIR_INTERNAL, cbLocal);
+ HookShape.newCallbackInfoReturnableIfNeeded(this, kind, CIR_INTERNAL, cbLocal, /*method*/ "tail:" + this.id, /*cancellable*/ false);
- this.mv.visitVarInsn(ALOAD, cbLocal);
- HookShape.loadReturnValueFromLocal(this.mv, this.targetDesc, retLocal);
- HookShape.emitCirSetReturn(this.mv, CIR_INTERNAL, ret);
+ this.visitVarInsn(ALOAD, cbLocal);
+ HookShape.loadReturnValueFromLocal(this, this.targetDesc, retLocal);
+ HookShape.emitCirSetReturn(this, CIR_INTERNAL, ret);
- HookShape.emitLoadCallbackInfoReturnableIfNeeded(this.mv, kind, cbLocal);
+ HookShape.emitLoadCallbackInfoReturnableIfNeeded(this, kind, cbLocal);
}
super.visitMethodInsn(INVOKESTATIC, this.hook.owner(), this.hook.name(), this.hook.desc(), false);
@@ -254,10 +254,10 @@ public void visitInsn(final int opcode) {
if (!isVoid) {
if (usesCIR) {
- this.mv.visitVarInsn(ALOAD, cbLocal);
- HookShape.emitCirGetReturn(this.mv, CIR_INTERNAL, ret);
+ this.visitVarInsn(ALOAD, cbLocal);
+ HookShape.emitCirGetReturn(this, CIR_INTERNAL, ret);
} else {
- HookShape.loadReturnValueFromLocal(this.mv, this.targetDesc, retLocal);
+ HookShape.loadReturnValueFromLocal(this, this.targetDesc, retLocal);
}
}
diff --git a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
index 8d62d51..5081683 100644
--- a/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
+++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java
@@ -465,13 +465,19 @@ public static int emitArgs(@NotNull final MethodVisitor mv,
* @param kind matched hook shape; must not be {@code null}
* @param ciInternalName internal JVM name of {@code CallbackInfo}; must not be {@code null} if {@code kind} uses CI
* @param ciLocal local slot index to store the new instance (must be a free local)
+ * @param methodName name of the target method (for debugging/logging purposes)
+ * @param cancellable whether the target method is cancellable (for debugging/logging purposes)
* @return {@code ciLocal} if a new instance was created; {@code -1} if the shape does not use {@code CallbackInfo}
* @throws IllegalArgumentException if the shape uses CI but {@code ciInternalName} is {@code null}
*/
- public static int newCallbackInfoIfNeeded(@NotNull final MethodVisitor mv,
- @NotNull final Kind kind,
- @Nullable final String ciInternalName,
- final int ciLocal) {
+ public static int newCallbackInfoIfNeeded(
+ @NotNull final MethodVisitor mv,
+ @NotNull final Kind kind,
+ @Nullable final String ciInternalName,
+ final int ciLocal,
+ @NotNull final String methodName,
+ final boolean cancellable
+ ) {
if (!kind.usesCallbackInfo()) {
return -1;
}
@@ -480,7 +486,9 @@ public static int newCallbackInfoIfNeeded(@NotNull final MethodVisitor mv,
}
mv.visitTypeInsn(Opcodes.NEW, ciInternalName);
mv.visitInsn(Opcodes.DUP);
- mv.visitMethodInsn(Opcodes.INVOKESPECIAL, ciInternalName, "", "()V", false);
+ mv.visitLdcInsn(methodName);
+ mv.visitInsn(cancellable ? Opcodes.ICONST_1 : Opcodes.ICONST_0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, ciInternalName, "", "(Ljava/lang/String;Z)V", false);
mv.visitVarInsn(Opcodes.ASTORE, ciLocal);
return ciLocal;
}
@@ -556,10 +564,19 @@ public static void emitReturnFor(@NotNull final MethodVisitor mv, @NotNull final
* @param kind matched hook shape; must not be {@code null}
* @param cirInternalName internal JVM name of {@code CallbackInfoReturnable}; must not be {@code null} if {@code kind} uses CIR
* @param cirLocal local slot index to store the new instance (must be a free local)
+ * @param methodName name of the target method (for debugging/logging purposes)
+ * @param cancellable whether the target method is cancellable (for debugging/logging purposes)
* @return {@code cirLocal} if a new instance was created; {@code -1} if the shape does not use {@code CallbackInfoReturnable}
* @throws IllegalArgumentException if the shape uses CIR but {@code cirInternalName} is {@code null}
*/
- public static int newCallbackInfoReturnableIfNeeded(@NotNull final MethodVisitor mv, @NotNull final Kind kind, @Nullable final String cirInternalName, final int cirLocal) {
+ public static int newCallbackInfoReturnableIfNeeded(
+ @NotNull final MethodVisitor mv,
+ @NotNull final Kind kind,
+ @Nullable final String cirInternalName,
+ final int cirLocal,
+ @NotNull final String methodName,
+ final boolean cancellable
+ ) {
if (!kind.usesCallbackInfoReturnable()) {
return -1;
}
@@ -568,7 +585,9 @@ public static int newCallbackInfoReturnableIfNeeded(@NotNull final MethodVisitor
}
mv.visitTypeInsn(Opcodes.NEW, cirInternalName);
mv.visitInsn(Opcodes.DUP);
- mv.visitMethodInsn(Opcodes.INVOKESPECIAL, cirInternalName, "", "()V", false);
+ mv.visitLdcInsn(methodName);
+ mv.visitInsn(cancellable ? Opcodes.ICONST_1 : Opcodes.ICONST_0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, cirInternalName, "", "(Ljava/lang/String;Z)V", false);
mv.visitVarInsn(Opcodes.ASTORE, cirLocal);
return cirLocal;
}
@@ -624,16 +643,44 @@ public static String cirSetterDescFor(@NotNull final Type ret) {
}
/**
- * Emits a call to {@code CallbackInfoReturnable.getReturn()}.
+ * Emits a call to {@code CallbackInfoReturnable.getReturn()} and casts/unboxes the result to the expected return type.
*
* @param mv downstream method visitor; must not be {@code null}
* @param cirInternal internal JVM name of {@code CallbackInfoReturnable}; must not be {@code null}
* @param ret the return type of the target method; must not be {@code null}
+ * @throws IllegalArgumentException if the return type is {@code void} or unsupported
*/
- public static void emitCirGetReturn(@NotNull final MethodVisitor mv, @NotNull final String cirInternal, @NotNull final Type ret) {
- mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturn", cirGetterDescFor(ret), false);
+ public static void emitCirGetReturn(@NotNull final MethodVisitor mv,
+ @NotNull final String cirInternal,
+ @NotNull final Type ret) {
+ switch (ret.getSort()) {
+ case Type.VOID -> throw new IllegalArgumentException("VOID has no return value");
+ case Type.BOOLEAN ->
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturnValueAsBoolean", "()Z", false);
+ case Type.BYTE ->
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturnValueAsByte", "()B", false);
+ case Type.SHORT ->
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturnValueAsShort", "()S", false);
+ case Type.CHAR ->
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturnValueAsChar", "()C", false);
+ case Type.INT ->
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturnValueAsInt", "()I", false);
+ case Type.LONG ->
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturnValueAsLong", "()J", false);
+ case Type.FLOAT ->
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturnValueAsFloat", "()F", false);
+ case Type.DOUBLE ->
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturnValueAsDouble", "()D", false);
+ case Type.ARRAY, Type.OBJECT -> {
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "getReturnValue", "()Ljava/lang/Object;", false);
+ // Cast to the expected reference/array type
+ mv.visitTypeInsn(Opcodes.CHECKCAST, ret.getInternalName());
+ }
+ default -> throw new IllegalArgumentException("Unsupported return type for CIR get: " + ret);
+ }
}
+
/**
* Emits a call to {@code CallbackInfoReturnable.setReturn(R)}.
*
@@ -641,8 +688,48 @@ public static void emitCirGetReturn(@NotNull final MethodVisitor mv, @NotNull fi
* @param cirInternal internal JVM name of {@code CallbackInfoReturnable}; must not be {@code null}
* @param ret the return type of the target method; must not be {@code null}
*/
- public static void emitCirSetReturn(@NotNull final MethodVisitor mv, @NotNull final String cirInternal, @NotNull final Type ret) {
- mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturn", cirSetterDescFor(ret), false);
+ public static void emitCirSetReturn(@NotNull final MethodVisitor mv,
+ @NotNull final String cirInternal,
+ @NotNull final Type ret) {
+ switch (ret.getSort()) {
+ case Type.VOID -> throw new IllegalArgumentException("VOID has no return value");
+ case Type.BOOLEAN -> {
+ // Stack: ..., cir, (Z)
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Boolean", "valueOf", "(Z)Ljava/lang/Boolean;", false);
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturnValue", "(Ljava/lang/Object;)V", false);
+ }
+ case Type.BYTE -> {
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Byte", "valueOf", "(B)Ljava/lang/Byte;", false);
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturnValue", "(Ljava/lang/Object;)V", false);
+ }
+ case Type.SHORT -> {
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Short", "valueOf", "(S)Ljava/lang/Short;", false);
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturnValue", "(Ljava/lang/Object;)V", false);
+ }
+ case Type.CHAR -> {
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Character", "valueOf", "(C)Ljava/lang/Character;", false);
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturnValue", "(Ljava/lang/Object;)V", false);
+ }
+ case Type.INT -> {
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Integer", "valueOf", "(I)Ljava/lang/Integer;", false);
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturnValue", "(Ljava/lang/Object;)V", false);
+ }
+ case Type.LONG -> {
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Long", "valueOf", "(J)Ljava/lang/Long;", false);
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturnValue", "(Ljava/lang/Object;)V", false);
+ }
+ case Type.FLOAT -> {
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Float", "valueOf", "(F)Ljava/lang/Float;", false);
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturnValue", "(Ljava/lang/Object;)V", false);
+ }
+ case Type.DOUBLE -> {
+ mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Double", "valueOf", "(D)Ljava/lang/Double;", false);
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturnValue", "(Ljava/lang/Object;)V", false);
+ }
+ case Type.ARRAY, Type.OBJECT ->
+ mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, cirInternal, "setReturnValue", "(Ljava/lang/Object;)V", false);
+ default -> throw new IllegalArgumentException("Unsupported return type for CIR set: " + ret);
+ }
}
/**
From 3bf39c9709b2189df84058a4427e66f9be532ad8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Mon, 15 Sep 2025 13:17:08 +0200
Subject: [PATCH 12/14] Add e2e tests for CI/CIR mixin behaviors:
- Introduce test resources for validating CI/CIR usage, including head, tail, and cancellation scenarios.
- Add negative tests for invalid CI and CIR application (e.g., CI on non-void or CIR on void methods).
- Validate CIR behavior for return value manipulation and cancellation.
- Verify CI side effects and constructor injection support.
---
.../test/java/e2e/cicir/CancelVoidMain.java | 11 ++
.../src/test/java/e2e/cicir/CiCiVoidMain.java | 8 ++
.../src/test/java/e2e/cicir/CiCirE2E.java | 126 ++++++++++++++++++
.../test/java/e2e/cicir/CiTailVoidMain.java | 10 ++
.../java/e2e/cicir/CirHeadNonVoidMain.java | 12 ++
.../src/test/java/e2e/cicir/CirTailMain.java | 11 ++
.../src/test/java/e2e/cicir/CtorCiMain.java | 11 ++
.../e2e/cicir/mixins/HeadCiCancelMixin.java | 15 +++
.../e2e/cicir/mixins/HeadCiCtorMixin.java | 18 +++
.../cicir/mixins/HeadCiVoidCancelMixin.java | 19 +++
.../mixins/HeadCirNonVoidCancelMixin.java | 19 +++
.../cicir/mixins/InvalidCiNonVoidMixin.java | 18 +++
.../e2e/cicir/mixins/InvalidCirVoidMixin.java | 18 +++
.../cicir/mixins/TailCiSideEffectMixin.java | 18 +++
.../cicir/mixins/TailCirOverrideMixin.java | 18 +++
.../test/resources/mixins_ci_head_cancel.yml | 8 ++
.../test/resources/mixins_ci_head_ctor.yml | 8 ++
.../resources/mixins_ci_head_void_cancel.yml | 8 ++
.../resources/mixins_ci_tail_sideeffect.yml | 8 ++
.../mixins_cir_head_nonvoid_cancel.yml | 8 ++
.../resources/mixins_cir_tail_override.yml | 8 ++
.../mixins_invalid_ci_on_nonvoid.yml | 8 ++
.../resources/mixins_invalid_cir_on_void.yml | 8 ++
23 files changed, 396 insertions(+)
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/CancelVoidMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/CiCiVoidMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/CiTailVoidMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/CirHeadNonVoidMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/CirTailMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/CtorCiMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCtorMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiVoidCancelMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirNonVoidCancelMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/InvalidCiNonVoidMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/InvalidCirVoidMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCiSideEffectMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirOverrideMixin.java
create mode 100644 aether-mixins-tests/src/test/resources/mixins_ci_head_cancel.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_ci_head_ctor.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_ci_head_void_cancel.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_ci_tail_sideeffect.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_cir_head_nonvoid_cancel.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_cir_tail_override.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_invalid_ci_on_nonvoid.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_invalid_cir_on_void.yml
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CancelVoidMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/CancelVoidMain.java
new file mode 100644
index 0000000..997487a
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CancelVoidMain.java
@@ -0,0 +1,11 @@
+package e2e.cicir;
+
+public class CancelVoidMain {
+ public static void main(final String[] args) {
+ body();
+ }
+
+ static void body() {
+ System.out.print("BODY");
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CiCiVoidMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/CiCiVoidMain.java
new file mode 100644
index 0000000..4d3fbbc
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CiCiVoidMain.java
@@ -0,0 +1,8 @@
+package e2e.cicir;
+
+public class CiCiVoidMain {
+ public static void main(final String[] args) {
+ // If CI HEAD cancels, this line must not print
+ System.out.println("ORIG-VOID");
+ }
+}
\ No newline at end of file
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java b/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java
new file mode 100644
index 0000000..2d4b1ef
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java
@@ -0,0 +1,126 @@
+package e2e.cicir;
+
+import de.splatgames.aether.mixins.testkit.JvmRunner;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class CiCirE2E {
+
+ // --- HEAD: CI (void) cancels early ---
+ @Test
+ void headCiCancelsVoidTarget() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_ci_head_void_cancel.yml");
+ assertNotNull(url, "mixins_ci_head_void_cancel.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ // Target prints "ORIG-VOID" if body runs; CI should cancel -> expect "HEAD-CI-CANCELLED"
+ var r = JvmRunner.runWithAgent("e2e.cicir.CiCiVoidMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+", " ").trim();
+ assertTrue(out.contains("HEAD-CI-CANCELLED"), () -> "Expected HEAD-CI-CANCELLED in output\n" + r.stdout);
+ assertFalse(out.contains("ORIG-VOID"), () -> "Body must be skipped by CI cancel\n" + r.stdout);
+ }
+
+ // --- HEAD: CIR (non-void) cancels and returns replacement value ---
+ @Test
+ void headCirCancelsNonVoidReturnsReplacement() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_cir_head_nonvoid_cancel.yml");
+ assertNotNull(url, "mixins_cir_head_nonvoid_cancel.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ // Target would return 7; CIR cancels and returns 42 instead.
+ var r = JvmRunner.runWithAgent("e2e.cicir.CirHeadNonVoidMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+", " ").trim();
+ assertTrue(out.contains("RET=42"), () -> "Expected replacement return value via CIR (42)\n" + r.stdout);
+ assertFalse(out.contains("RET=7"), () -> "Original value must be skipped\n" + r.stdout);
+ }
+
+ // --- TAIL: CIR overrides final return value ---
+ @Test
+ void tailCirOverridesReturn() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_cir_tail_override.yml");
+ assertNotNull(url, "mixins_cir_tail_override.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ // Target returns "ORIG"; TAIL CIR sets "TAIL-OVERRIDE"
+ var r = JvmRunner.runWithAgent("e2e.cicir.CirTailMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+", " ").trim();
+ assertTrue(out.contains("RET=TAIL-OVERRIDE"), () -> "Expected CIR to override the return value\n" + r.stdout);
+ assertFalse(out.contains("RET=ORIG"), () -> "Original should be replaced\n" + r.stdout);
+ }
+
+ // --- TAIL: CI (void) side-effect only (no cancel) ---
+ @Test
+ void tailCiSideEffectOnly() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_ci_tail_sideeffect.yml");
+ assertNotNull(url, "mixins_ci_tail_sideeffect.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ // Target prints "BODY-DONE"; TAIL CI appends "[TAIL-CI]" (no cancel semantics)
+ var r = JvmRunner.runWithAgent("e2e.cicir.CiTailVoidMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+", " ").trim();
+ assertTrue(out.contains("[TAIL-CI]BODY-DONE"),
+ () -> "Expected side-effect marker from CI tail\n" + r.stdout);
+ }
+
+ // --- HEAD in : CI works (void only), body runs after super() ---
+ @Test
+ void headCiInCtor() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_ci_head_ctor.yml");
+ assertNotNull(url, "mixins_ci_head_ctor.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ // Constructor prints "CTOR:BODY"; HEAD CI prints "[HEAD-CI]" after super-call
+ var r = JvmRunner.runWithAgent("e2e.cicir.CtorCiMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+", " ").trim();
+ assertTrue(out.contains("[HEAD-CI] CTOR:BODY"), () -> "Expected CI head after call\n" + r.stdout);
+ }
+
+ // --- Negative: invalid CI/CIR usage should fail (non-optional) ---
+ @Test
+ void invalidCiOnNonVoidFails() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_invalid_ci_on_nonvoid.yml");
+ assertNotNull(url, "mixins_invalid_ci_on_nonvoid.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.CirHeadNonVoidMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertNotEquals(0, r.exitCode, () -> "Expected failure when CI used on non-void\n--- STDERR ---\n" + r.stderr);
+ }
+
+ @Test
+ void invalidCirOnVoidFails() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_invalid_cir_on_void.yml");
+ assertNotNull(url, "mixins_invalid_cir_on_void.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.CiCiVoidMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertNotEquals(0, r.exitCode, () -> "Expected failure when CIR used on void\n--- STDERR ---\n" + r.stderr);
+ }
+
+ @Test
+ void headCiCancelsBody() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_ci_head_cancel.yml");
+ assertNotNull(url, "mixins_ci_head_cancel.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.CancelVoidMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+
+ var out = r.stdout.replaceAll("\\s+", " ").trim();
+ assertTrue(out.contains("[HEAD]"), () -> "Expected HEAD marker\n" + r.stdout);
+ assertFalse(out.contains("BODY"), () -> "Body must be skipped by CI cancel\n" + r.stdout);
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CiTailVoidMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/CiTailVoidMain.java
new file mode 100644
index 0000000..989598e
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CiTailVoidMain.java
@@ -0,0 +1,10 @@
+package e2e.cicir;
+
+public class CiTailVoidMain {
+ public static void main(final String[] args) {
+ body();
+ System.out.println("BODY-DONE");
+ }
+
+ static void body() { /* empty */ }
+}
\ No newline at end of file
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CirHeadNonVoidMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/CirHeadNonVoidMain.java
new file mode 100644
index 0000000..efe40e1
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CirHeadNonVoidMain.java
@@ -0,0 +1,12 @@
+package e2e.cicir;
+
+public class CirHeadNonVoidMain {
+ public static void main(final String[] args) {
+ int v = target();
+ System.out.println("RET=" + v);
+ }
+
+ static int target() {
+ return 7;
+ }
+}
\ No newline at end of file
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CirTailMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/CirTailMain.java
new file mode 100644
index 0000000..6ed1de9
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CirTailMain.java
@@ -0,0 +1,11 @@
+package e2e.cicir;
+
+public class CirTailMain {
+ public static void main(final String[] args) {
+ System.out.println("RET=" + target());
+ }
+
+ static String target() {
+ return "RET=ORIG".substring(4); /* "ORIG" */
+ }
+}
\ No newline at end of file
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CtorCiMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/CtorCiMain.java
new file mode 100644
index 0000000..c98a380
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CtorCiMain.java
@@ -0,0 +1,11 @@
+package e2e.cicir;
+
+public class CtorCiMain {
+ public static void main(final String[] args) {
+ new CtorCiMain();
+ }
+
+ public CtorCiMain() {
+ System.out.print("CTOR:BODY");
+ }
+}
\ No newline at end of file
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.java
new file mode 100644
index 0000000..c97a9fc
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.java
@@ -0,0 +1,15 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfo;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CancelVoidMain")
+public final class HeadCiCancelMixin {
+ @Inject(method = "body()V", at = Inject.At.HEAD, id = "head-ci-cancel")
+ public static void head(final CallbackInfo ci) {
+ System.out.print("[HEAD]");
+ // assume API provides cancel(); if es heißt anders (setCancelled(true)), entsprechend anpassen
+ ci.cancel();
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCtorMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCtorMixin.java
new file mode 100644
index 0000000..0df1622
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCtorMixin.java
@@ -0,0 +1,18 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfo;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CtorCiMain")
+public final class HeadCiCtorMixin {
+
+ @Inject(
+ method = "()V",
+ at = Inject.At.HEAD,
+ id = "head-ci-ctor"
+ )
+ public static void head(final CallbackInfo ci) {
+ System.out.print("[HEAD-CI] ");
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiVoidCancelMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiVoidCancelMixin.java
new file mode 100644
index 0000000..6917373
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiVoidCancelMixin.java
@@ -0,0 +1,19 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfo;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CiCiVoidMain")
+public final class HeadCiVoidCancelMixin {
+
+ @Inject(
+ method = "main([Ljava/lang/String;)V",
+ at = Inject.At.HEAD,
+ id = "head-ci-void-cancel"
+ )
+ public static void head(final String[] args, final CallbackInfo ci) {
+ System.out.println("HEAD-CI-CANCELLED");
+ ci.cancel();
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirNonVoidCancelMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirNonVoidCancelMixin.java
new file mode 100644
index 0000000..26e39bf
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirNonVoidCancelMixin.java
@@ -0,0 +1,19 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CirHeadNonVoidMain")
+public final class HeadCirNonVoidCancelMixin {
+
+ @Inject(
+ method = "target()I",
+ at = Inject.At.HEAD,
+ id = "head-cir-nonvoid-cancel"
+ )
+ public static void head(final CallbackInfoReturnable cir) {
+ cir.setReturnValue(42);
+ cir.cancel();
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/InvalidCiNonVoidMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/InvalidCiNonVoidMixin.java
new file mode 100644
index 0000000..3a90039
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/InvalidCiNonVoidMixin.java
@@ -0,0 +1,18 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfo;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CirHeadNonVoidMain")
+public final class InvalidCiNonVoidMixin {
+
+ @Inject(
+ method = "target()I",
+ at = Inject.At.HEAD,
+ id = "invalid-ci-nonvoid"
+ )
+ public static void head(final CallbackInfo ci) {
+ // This is intentionally invalid: CI on non-void target.
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/InvalidCirVoidMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/InvalidCirVoidMixin.java
new file mode 100644
index 0000000..2c17ada
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/InvalidCirVoidMixin.java
@@ -0,0 +1,18 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CiCiVoidMain")
+public final class InvalidCirVoidMixin {
+
+ @Inject(
+ method = "main([Ljava/lang/String;)V",
+ at = Inject.At.HEAD,
+ id = "invalid-cir-void"
+ )
+ public static void head(final String[] args, final CallbackInfoReturnable cir) {
+ // This is intentionally invalid: CIR on a void target.
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCiSideEffectMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCiSideEffectMixin.java
new file mode 100644
index 0000000..dc6cbb3
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCiSideEffectMixin.java
@@ -0,0 +1,18 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfo;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CiTailVoidMain")
+public final class TailCiSideEffectMixin {
+
+ @Inject(
+ method = "body()V",
+ at = Inject.At.TAIL,
+ id = "tail-ci-sideeffect"
+ )
+ public static void tail(final CallbackInfo ci) {
+ System.out.print("[TAIL-CI]");
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirOverrideMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirOverrideMixin.java
new file mode 100644
index 0000000..e76c775
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirOverrideMixin.java
@@ -0,0 +1,18 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CirTailMain")
+public final class TailCirOverrideMixin {
+
+ @Inject(
+ method = "target()Ljava/lang/String;",
+ at = Inject.At.TAIL,
+ id = "tail-cir-override"
+ )
+ public static void tail(final CallbackInfoReturnable cir) {
+ cir.setReturnValue("TAIL-OVERRIDE");
+ }
+}
diff --git a/aether-mixins-tests/src/test/resources/mixins_ci_head_cancel.yml b/aether-mixins-tests/src/test/resources/mixins_ci_head_cancel.yml
new file mode 100644
index 0000000..e62c571
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_ci_head_cancel.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-ci-head-cancel
+ classes:
+ - e2e.cicir.mixins.HeadCiCancelMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_ci_head_ctor.yml b/aether-mixins-tests/src/test/resources/mixins_ci_head_ctor.yml
new file mode 100644
index 0000000..dce2f54
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_ci_head_ctor.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-ci-head-ctor
+ classes:
+ - e2e.cicir.mixins.HeadCiCtorMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_ci_head_void_cancel.yml b/aether-mixins-tests/src/test/resources/mixins_ci_head_void_cancel.yml
new file mode 100644
index 0000000..262d170
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_ci_head_void_cancel.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-ci-head-void-cancel
+ classes:
+ - e2e.cicir.mixins.HeadCiVoidCancelMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_ci_tail_sideeffect.yml b/aether-mixins-tests/src/test/resources/mixins_ci_tail_sideeffect.yml
new file mode 100644
index 0000000..64564bb
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_ci_tail_sideeffect.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-ci-tail-sideeffect
+ classes:
+ - e2e.cicir.mixins.TailCiSideEffectMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_cir_head_nonvoid_cancel.yml b/aether-mixins-tests/src/test/resources/mixins_cir_head_nonvoid_cancel.yml
new file mode 100644
index 0000000..d462421
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_cir_head_nonvoid_cancel.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-cir-head-nonvoid-cancel
+ classes:
+ - e2e.cicir.mixins.HeadCirNonVoidCancelMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_cir_tail_override.yml b/aether-mixins-tests/src/test/resources/mixins_cir_tail_override.yml
new file mode 100644
index 0000000..26e488f
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_cir_tail_override.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-cir-tail-override
+ classes:
+ - e2e.cicir.mixins.TailCirOverrideMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_invalid_ci_on_nonvoid.yml b/aether-mixins-tests/src/test/resources/mixins_invalid_ci_on_nonvoid.yml
new file mode 100644
index 0000000..2045348
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_invalid_ci_on_nonvoid.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-invalid-ci-nonvoid
+ classes:
+ - e2e.cicir.mixins.InvalidCiNonVoidMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_invalid_cir_on_void.yml b/aether-mixins-tests/src/test/resources/mixins_invalid_cir_on_void.yml
new file mode 100644
index 0000000..8e44b8c
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_invalid_cir_on_void.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-invalid-cir-void
+ classes:
+ - e2e.cicir.mixins.InvalidCirVoidMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
From 344d115866ef9fa0bc49fdec675ba9de0cd3f62d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Mon, 15 Sep 2025 13:21:54 +0200
Subject: [PATCH 13/14] Add e2e tests for advanced CI/CIR injection scenarios:
- Add comprehensive tests for CI and CIR behaviors, including head injections, return manipulation, and method cancellations.
- Validate constructor injection and scenarios with and without return value manipulation.
- Verify proper handling of mixin configurations for runtime behaviors and detailed assertions.
---
.../src/test/java/e2e/cicir/CiCirE2E.java | 56 +++++++++++++++++++
.../test/java/e2e/cicir/CtorCancelMain.java | 11 ++++
.../test/java/e2e/cicir/HeadCirIntMain.java | 11 ++++
.../e2e/cicir/mixins/HeadCiCancelMixin.java | 1 -
.../cicir/mixins/HeadCiCtorCancelMixin.java | 14 +++++
.../e2e/cicir/mixins/HeadCiNoCancelMixin.java | 13 +++++
.../cicir/mixins/HeadCirCancelNoSetMixin.java | 13 +++++
.../cicir/mixins/HeadCirCancelSetMixin.java | 14 +++++
.../resources/mixins_ci_head_ctor_cancel.yml | 8 +++
.../resources/mixins_ci_head_nocancel.yml | 8 +++
.../mixins_cir_head_cancel_noset.yml | 8 +++
.../resources/mixins_cir_head_cancel_set.yml | 8 +++
12 files changed, 164 insertions(+), 1 deletion(-)
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/CtorCancelMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/HeadCirIntMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCtorCancelMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiNoCancelMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirCancelNoSetMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirCancelSetMixin.java
create mode 100644 aether-mixins-tests/src/test/resources/mixins_ci_head_ctor_cancel.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_ci_head_nocancel.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_cir_head_cancel_noset.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_cir_head_cancel_set.yml
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java b/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java
index 2d4b1ef..df6f7fb 100644
--- a/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java
@@ -123,4 +123,60 @@ void headCiCancelsBody() throws Exception {
assertTrue(out.contains("[HEAD]"), () -> "Expected HEAD marker\n" + r.stdout);
assertFalse(out.contains("BODY"), () -> "Body must be skipped by CI cancel\n" + r.stdout);
}
+
+ @Test
+ void headCiNoCancelBodyRuns() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_ci_head_nocancel.yml");
+ assertNotNull(url, "mixins_ci_head_nocancel.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.CancelVoidMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+
+ var out = r.stdout.replaceAll("\\s+", "").trim();
+ assertTrue(out.contains("[HEAD]BODY"), () -> "Expected HEAD then BODY\n" + r.stdout);
+ }
+
+ @Test
+ void headCirCancelSetsReturn() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_cir_head_cancel_set.yml");
+ assertNotNull(url, "mixins_cir_head_cancel_set.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.HeadCirIntMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+
+ var out = r.stdout.replaceAll("\\s+", " ").trim();
+ assertTrue(out.contains("RET=42"), () -> "Expected replacement return 42\n" + r.stdout);
+ assertFalse(out.contains("RET=7"), () -> "Original must be skipped\n" + r.stdout);
+ }
+
+ @Test
+ void headCirCancelWithoutSetReturn() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_cir_head_cancel_noset.yml");
+ assertNotNull(url, "mixins_cir_head_cancel_noset.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.HeadCirIntMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+
+ var out = r.stdout.replaceAll("\\s+", "").trim();
+ assertTrue(out.contains("RET=0"), () -> "Expected default int return (0) when cancelled w/o setReturn\n" + r.stdout);
+ assertFalse(out.contains("RET=7"), () -> "Original must not appear\n" + r.stdout);
+ }
+
+ @Test
+ void headCiCancelCtorSkipsBody() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_ci_head_ctor_cancel.yml");
+ assertNotNull(url, "mixins_ci_head_ctor_cancel.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.CtorCancelMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+
+ var out = r.stdout.replaceAll("\\s+", " ").trim();
+ assertTrue(out.contains("[HEAD-CI]"), () -> "Expected HEAD-CI marker\n" + r.stdout);
+ assertFalse(out.contains("CTOR:BODY"), () -> "Ctor body must be skipped\n" + r.stdout);
+ }
+
}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CtorCancelMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/CtorCancelMain.java
new file mode 100644
index 0000000..b41378a
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CtorCancelMain.java
@@ -0,0 +1,11 @@
+package e2e.cicir;
+
+public class CtorCancelMain {
+ public static void main(final String[] args) {
+ new CtorCancelMain();
+ }
+
+ public CtorCancelMain() {
+ System.out.print("CTOR:BODY");
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/HeadCirIntMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/HeadCirIntMain.java
new file mode 100644
index 0000000..e3e6c35
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/HeadCirIntMain.java
@@ -0,0 +1,11 @@
+package e2e.cicir;
+
+public class HeadCirIntMain {
+ public static void main(final String[] args) {
+ System.out.print("RET=" + target());
+ }
+
+ static int target() {
+ return 7;
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.java
index c97a9fc..2221482 100644
--- a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.java
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.java
@@ -9,7 +9,6 @@ public final class HeadCiCancelMixin {
@Inject(method = "body()V", at = Inject.At.HEAD, id = "head-ci-cancel")
public static void head(final CallbackInfo ci) {
System.out.print("[HEAD]");
- // assume API provides cancel(); if es heißt anders (setCancelled(true)), entsprechend anpassen
ci.cancel();
}
}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCtorCancelMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCtorCancelMixin.java
new file mode 100644
index 0000000..4dbc07f
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCtorCancelMixin.java
@@ -0,0 +1,14 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfo;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CtorCancelMain")
+public final class HeadCiCtorCancelMixin {
+ @Inject(method = "()V", at = Inject.At.HEAD, id = "head-ci-ctor-cancel")
+ public static void head(final CallbackInfo ci) {
+ System.out.print("[HEAD-CI]");
+ ci.cancel();
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiNoCancelMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiNoCancelMixin.java
new file mode 100644
index 0000000..5c75266
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiNoCancelMixin.java
@@ -0,0 +1,13 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfo;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.CancelVoidMain")
+public final class HeadCiNoCancelMixin {
+ @Inject(method = "body()V", at = Inject.At.HEAD, id = "head-ci-nocancel")
+ public static void head(final CallbackInfo ci) {
+ System.out.print("[HEAD]");
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirCancelNoSetMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirCancelNoSetMixin.java
new file mode 100644
index 0000000..a5937e7
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirCancelNoSetMixin.java
@@ -0,0 +1,13 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.HeadCirIntMain")
+public final class HeadCirCancelNoSetMixin {
+ @Inject(method = "target()I", at = Inject.At.HEAD, id = "head-cir-cancel-noset")
+ public static void head(final CallbackInfoReturnable cir) {
+ cir.cancel();
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirCancelSetMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirCancelSetMixin.java
new file mode 100644
index 0000000..ffdd656
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCirCancelSetMixin.java
@@ -0,0 +1,14 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.HeadCirIntMain")
+public final class HeadCirCancelSetMixin {
+ @Inject(method = "target()I", at = Inject.At.HEAD, id = "head-cir-cancel-set")
+ public static void head(final CallbackInfoReturnable cir) {
+ cir.setReturnValue(42);
+ cir.cancel();
+ }
+}
diff --git a/aether-mixins-tests/src/test/resources/mixins_ci_head_ctor_cancel.yml b/aether-mixins-tests/src/test/resources/mixins_ci_head_ctor_cancel.yml
new file mode 100644
index 0000000..a2cd019
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_ci_head_ctor_cancel.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-ci-head-ctor-cancel
+ classes:
+ - e2e.cicir.mixins.HeadCiCtorCancelMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_ci_head_nocancel.yml b/aether-mixins-tests/src/test/resources/mixins_ci_head_nocancel.yml
new file mode 100644
index 0000000..89e017d
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_ci_head_nocancel.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-ci-head-nocancel
+ classes:
+ - e2e.cicir.mixins.HeadCiNoCancelMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_cir_head_cancel_noset.yml b/aether-mixins-tests/src/test/resources/mixins_cir_head_cancel_noset.yml
new file mode 100644
index 0000000..d6369cf
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_cir_head_cancel_noset.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-cir-head-cancel-noset
+ classes:
+ - e2e.cicir.mixins.HeadCirCancelNoSetMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_cir_head_cancel_set.yml b/aether-mixins-tests/src/test/resources/mixins_cir_head_cancel_set.yml
new file mode 100644
index 0000000..256fb58
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_cir_head_cancel_set.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-cir-head-cancel-set
+ classes:
+ - e2e.cicir.mixins.HeadCirCancelSetMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
From 84de4d5729d5a896284e017fd87c5f7f8e59a314 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?=
Date: Mon, 15 Sep 2025 13:44:16 +0200
Subject: [PATCH 14/14] Add e2e tests for mixin injection scenarios:
- Introduce tests for CI and CIR behaviors, including head injections, tail overrides, synchronized method handling, and return value manipulation.
- Add priority resolution tests for CIR tail injections.
- Validate mixin configurations and assertions for null and static behavior.
- Add negative test for invalid CI usage on static methods.
---
.../java/e2e/cicir/BadThisCiStaticMain.java | 9 ++
.../src/test/java/e2e/cicir/CiCirE2E.java | 101 ++++++++++++++++++
.../test/java/e2e/cicir/NestedCancelMain.java | 16 +++
.../src/test/java/e2e/cicir/NullTailMain.java | 9 ++
.../test/java/e2e/cicir/PriorTailMain.java | 11 ++
.../test/java/e2e/cicir/StaticWideMain.java | 11 ++
.../src/test/java/e2e/cicir/SyncMain.java | 11 ++
.../test/java/e2e/cicir/ThisArgsCiMain.java | 12 +++
.../test/java/e2e/cicir/TryFinallyMain.java | 18 ++++
.../cicir/mixins/BadThisCiStaticMixin.java | 14 +++
.../cicir/mixins/HeadCiCancelOuterMixin.java | 14 +++
.../e2e/cicir/mixins/HeadThisArgsCiMixin.java | 20 ++++
.../java/e2e/cicir/mixins/TailCirHigh.java | 13 +++
.../java/e2e/cicir/mixins/TailCirLow.java | 13 +++
.../e2e/cicir/mixins/TailCirNullMixin.java | 13 +++
.../cicir/mixins/TailCirStaticWideMixin.java | 13 +++
.../e2e/cicir/mixins/TailCirSyncMixin.java | 13 +++
.../cicir/mixins/TailCirTryFinallyMixin.java | 13 +++
.../resources/mixins_ci_head_cancel_outer.yml | 8 ++
.../resources/mixins_ci_head_thisargs.yml | 8 ++
.../test/resources/mixins_cir_tail_null.yml | 8 ++
.../resources/mixins_cir_tail_priority.yml | 9 ++
.../resources/mixins_cir_tail_static_wide.yml | 8 ++
.../test/resources/mixins_cir_tail_sync.yml | 8 ++
.../resources/mixins_cir_tail_tryfinally.yml | 8 ++
.../mixins_invalid_thisci_on_static.yml | 8 ++
26 files changed, 389 insertions(+)
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/BadThisCiStaticMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/NestedCancelMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/NullTailMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/PriorTailMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/StaticWideMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/SyncMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/ThisArgsCiMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/TryFinallyMain.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/BadThisCiStaticMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelOuterMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadThisArgsCiMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirHigh.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirLow.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirNullMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirStaticWideMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirSyncMixin.java
create mode 100644 aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirTryFinallyMixin.java
create mode 100644 aether-mixins-tests/src/test/resources/mixins_ci_head_cancel_outer.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_ci_head_thisargs.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_cir_tail_null.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_cir_tail_priority.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_cir_tail_static_wide.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_cir_tail_sync.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_cir_tail_tryfinally.yml
create mode 100644 aether-mixins-tests/src/test/resources/mixins_invalid_thisci_on_static.yml
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/BadThisCiStaticMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/BadThisCiStaticMain.java
new file mode 100644
index 0000000..e767efe
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/BadThisCiStaticMain.java
@@ -0,0 +1,9 @@
+package e2e.cicir;
+
+public class BadThisCiStaticMain {
+ public static void main(final String[] args) {
+ foo();
+ }
+
+ static void foo() { /* no-op */ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java b/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java
index df6f7fb..e6ecd65 100644
--- a/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java
@@ -179,4 +179,105 @@ void headCiCancelCtorSkipsBody() throws Exception {
assertFalse(out.contains("CTOR:BODY"), () -> "Ctor body must be skipped\n" + r.stdout);
}
+ @Test
+ void tailCirOverridesAcrossTryCatchFinally() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_cir_tail_tryfinally.yml");
+ assertNotNull(url, "mixins_cir_tail_tryfinally.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.TryFinallyMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+", "").trim();
+
+ // Egal welcher Pfad (true/false), TAIL-CIR setzt final "OVR"
+ assertTrue(out.contains("RET=OVR"), () -> "Expected overridden return via TAIL-CIR\n" + r.stdout);
+ assertFalse(out.contains("RET=A") || out.contains("RET=B"), () -> "Original returns must be overridden\n" + r.stdout);
+ }
+
+ @Test
+ void tailCirOverridesInSynchronizedMethod() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_cir_tail_sync.yml");
+ assertNotNull(url, "mixins_cir_tail_sync.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.SyncMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+", "").trim();
+
+ assertTrue(out.contains("RET=SYNC"), () -> "Expected overridden return in synchronized method\n" + r.stdout);
+ assertFalse(out.contains("RET=ORIG"), () -> "Original must be replaced\n" + r.stdout);
+ }
+
+ @Test
+ void headCiCancelPreventsNestedCall() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_ci_head_cancel_outer.yml");
+ assertNotNull(url, "mixins_ci_head_cancel_outer.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.NestedCancelMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+", " ").trim();
+
+ assertTrue(out.contains("[HEAD-CANCEL]"), () -> "Expected head cancel marker\n" + r.stdout);
+ assertFalse(out.contains("INNER"), () -> "Inner call must not run when outer is cancelled\n" + r.stdout);
+ }
+
+ @Test
+ void tailCirOverrideStaticWide() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_cir_tail_static_wide.yml");
+ assertNotNull(url, "mixins_cir_tail_static_wide.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.StaticWideMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+","").trim();
+ assertTrue(out.contains("RET=123"), () -> "Expected overridden return via TAIL-CIR\n" + r.stdout);
+ }
+
+ @Test
+ void headThisArgsCiLoadsCorrectly() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_ci_head_thisargs.yml");
+ assertNotNull(url, "mixins_ci_head_thisargs.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.ThisArgsCiMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+","").trim();
+ assertTrue(out.contains("[HEAD:ok]BODY"), () -> "Expected HEAD side-effect before BODY\n" + r.stdout);
+ }
+
+ @Test
+ void tailCirPriorityLowWins() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_cir_tail_priority.yml");
+ assertNotNull(url, "mixins_cir_tail_priority.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.PriorTailMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+","").trim();
+ assertTrue(out.contains("RET=LOW"), () -> "Lower priority wins with current tail wrapping order\n" + r.stdout);
+ assertFalse(out.contains("RET=HIGH"));
+ }
+
+ @Test
+ void tailCirNullReturnRefType() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_cir_tail_null.yml");
+ assertNotNull(url, "mixins_cir_tail_null.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.NullTailMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertEquals(0, r.exitCode, r.stderr);
+ var out = r.stdout.replaceAll("\\s+","").trim();
+ assertTrue(out.contains("RET=null"), () -> "Expected null return via TAIL-CIR\n" + r.stdout);
+ }
+
+ @Test
+ void invalidThisCiOnStaticFails() throws Exception {
+ var url = ClassLoader.getSystemResource("mixins_invalid_thisci_on_static.yml");
+ assertNotNull(url, "mixins_invalid_thisci_on_static.yml not found");
+ var cfg = Paths.get(url.toURI()).toAbsolutePath().toString();
+
+ var r = JvmRunner.runWithAgent("e2e.cicir.BadThisCiStaticMain", List.of(), Map.of("aether.mixins.config", cfg));
+ assertNotEquals(0, r.exitCode, () -> "Expected non-zero exit for THIS_CI on static\n--- STDERR ---\n" + r.stderr);
+ }
}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/NestedCancelMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/NestedCancelMain.java
new file mode 100644
index 0000000..374a485
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/NestedCancelMain.java
@@ -0,0 +1,16 @@
+package e2e.cicir;
+
+public class NestedCancelMain {
+ public static void main(final String[] args) {
+ outer();
+ }
+
+ static void outer() {
+ inner();
+ System.out.print(" OUTER-END");
+ }
+
+ static void inner() {
+ System.out.print("INNER");
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/NullTailMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/NullTailMain.java
new file mode 100644
index 0000000..b8663dd
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/NullTailMain.java
@@ -0,0 +1,9 @@
+package e2e.cicir;
+
+public class NullTailMain {
+ public static void main(final String[] args) {
+ String v = s();
+ System.out.print("RET=" + (v == null ? "null" : v));
+ }
+ static String s() { return "X"; }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/PriorTailMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/PriorTailMain.java
new file mode 100644
index 0000000..bdf40eb
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/PriorTailMain.java
@@ -0,0 +1,11 @@
+package e2e.cicir;
+
+public class PriorTailMain {
+ public static void main(final String[] args) {
+ System.out.print("RET=" + t());
+ }
+
+ static String t() {
+ return "ORIG";
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/StaticWideMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/StaticWideMain.java
new file mode 100644
index 0000000..ee85956
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/StaticWideMain.java
@@ -0,0 +1,11 @@
+package e2e.cicir;
+
+public class StaticWideMain {
+ public static void main(final String[] args) {
+ System.out.print("RET=" + f(11L, 2.5));
+ }
+
+ static int f(final long a, final double b) {
+ return (int) (a + b);
+ } // 13
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/SyncMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/SyncMain.java
new file mode 100644
index 0000000..9d6cc43
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/SyncMain.java
@@ -0,0 +1,11 @@
+package e2e.cicir;
+
+public class SyncMain {
+ public static void main(final String[] args) {
+ System.out.print("RET=" + f());
+ }
+
+ static synchronized String f() {
+ return "ORIG";
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/ThisArgsCiMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/ThisArgsCiMain.java
new file mode 100644
index 0000000..3eb465c
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/ThisArgsCiMain.java
@@ -0,0 +1,12 @@
+package e2e.cicir;
+
+public class ThisArgsCiMain {
+ public static void main(final String[] args) {
+ new ThisArgsCiMain().g(5, "X");
+ }
+
+ String g(final int i, final String s) {
+ System.out.print("BODY");
+ return s + i;
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/TryFinallyMain.java b/aether-mixins-tests/src/test/java/e2e/cicir/TryFinallyMain.java
new file mode 100644
index 0000000..8c0908d
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/TryFinallyMain.java
@@ -0,0 +1,18 @@
+package e2e.cicir;
+
+public class TryFinallyMain {
+ public static void main(final String[] args) {
+ System.out.print("RET=" + target(Boolean.getBoolean("p")));
+ }
+ static String target(boolean p) {
+ try {
+ if (p) return "A";
+ return "B";
+ } catch (RuntimeException e) {
+ return "C";
+ } finally {
+ // no-op but forces complex frames
+ int x = 1;
+ }
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/BadThisCiStaticMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/BadThisCiStaticMixin.java
new file mode 100644
index 0000000..1b18769
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/BadThisCiStaticMixin.java
@@ -0,0 +1,14 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfo;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+import e2e.cicir.BadThisCiStaticMain;
+
+@Mixin(targets = "e2e.cicir.BadThisCiStaticMain")
+public final class BadThisCiStaticMixin {
+ @Inject(method = "foo()V", at = Inject.At.HEAD, id = "bad-this-ci-static")
+ public static void head(final BadThisCiStaticMain self, final CallbackInfo ci) {
+ // invalid: static method hat kein 'this'
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelOuterMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelOuterMixin.java
new file mode 100644
index 0000000..d0eb492
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelOuterMixin.java
@@ -0,0 +1,14 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfo;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.NestedCancelMain")
+public final class HeadCiCancelOuterMixin {
+ @Inject(method = "outer()V", at = Inject.At.HEAD, id = "head-ci-cancel-outer")
+ public static void head(final CallbackInfo ci) {
+ System.out.print("[HEAD-CANCEL]");
+ ci.cancel();
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadThisArgsCiMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadThisArgsCiMixin.java
new file mode 100644
index 0000000..944c1ea
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadThisArgsCiMixin.java
@@ -0,0 +1,20 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+import e2e.cicir.ThisArgsCiMain;
+
+@Mixin(targets = "e2e.cicir.ThisArgsCiMain")
+public final class HeadThisArgsCiMixin {
+ @Inject(
+ method = "g(ILjava/lang/String;)Ljava/lang/String;",
+ at = Inject.At.HEAD,
+ id = "head-this-args-ci"
+ )
+ public static void head(final ThisArgsCiMain self, final int i, final String s, final CallbackInfoReturnable cir) {
+ if (self != null && i == 5 && "X".equals(s)) {
+ System.out.print("[HEAD:ok]");
+ }
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirHigh.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirHigh.java
new file mode 100644
index 0000000..7714056
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirHigh.java
@@ -0,0 +1,13 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.PriorTailMain", priority = 100) // HIGH
+public final class TailCirHigh {
+ @Inject(method = "t()Ljava/lang/String;", at = Inject.At.TAIL, id = "tail-cir-high")
+ public static void tail(final CallbackInfoReturnable cir) {
+ cir.setReturnValue("HIGH");
+ }
+}
\ No newline at end of file
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirLow.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirLow.java
new file mode 100644
index 0000000..9f41451
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirLow.java
@@ -0,0 +1,13 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.PriorTailMain", priority = 10) // LOW
+public final class TailCirLow {
+ @Inject(method = "t()Ljava/lang/String;", at = Inject.At.TAIL, id = "tail-cir-low")
+ public static void tail(final CallbackInfoReturnable cir) {
+ cir.setReturnValue("LOW");
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirNullMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirNullMixin.java
new file mode 100644
index 0000000..1e0eed3
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirNullMixin.java
@@ -0,0 +1,13 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.NullTailMain")
+public final class TailCirNullMixin {
+ @Inject(method = "s()Ljava/lang/String;", at = Inject.At.TAIL, id = "tail-cir-null")
+ public static void tail(final CallbackInfoReturnable cir) {
+ cir.setReturnValue(null);
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirStaticWideMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirStaticWideMixin.java
new file mode 100644
index 0000000..005b448
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirStaticWideMixin.java
@@ -0,0 +1,13 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.StaticWideMain")
+public final class TailCirStaticWideMixin {
+ @Inject(method = "f(JD)I", at = Inject.At.TAIL, id = "tail-cir-static-wide")
+ public static void tail(final CallbackInfoReturnable cir) {
+ cir.setReturnValue(123);
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirSyncMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirSyncMixin.java
new file mode 100644
index 0000000..6830bc5
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirSyncMixin.java
@@ -0,0 +1,13 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.SyncMain")
+public final class TailCirSyncMixin {
+ @Inject(method = "f()Ljava/lang/String;", at = Inject.At.TAIL, id = "tail-cir-sync")
+ public static void tail(final CallbackInfoReturnable cir) {
+ cir.setReturnValue("SYNC");
+ }
+}
diff --git a/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirTryFinallyMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirTryFinallyMixin.java
new file mode 100644
index 0000000..be57d5d
--- /dev/null
+++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/TailCirTryFinallyMixin.java
@@ -0,0 +1,13 @@
+package e2e.cicir.mixins;
+
+import de.splatgames.aether.mixins.core.api.CallbackInfoReturnable;
+import de.splatgames.aether.mixins.core.api.Inject;
+import de.splatgames.aether.mixins.core.api.Mixin;
+
+@Mixin(targets = "e2e.cicir.TryFinallyMain")
+public final class TailCirTryFinallyMixin {
+ @Inject(method = "target(Z)Ljava/lang/String;", at = Inject.At.TAIL, id = "tail-cir-tryfinally")
+ public static void tail(final CallbackInfoReturnable cir) {
+ cir.setReturnValue("OVR");
+ }
+}
diff --git a/aether-mixins-tests/src/test/resources/mixins_ci_head_cancel_outer.yml b/aether-mixins-tests/src/test/resources/mixins_ci_head_cancel_outer.yml
new file mode 100644
index 0000000..69ec8df
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_ci_head_cancel_outer.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-ci-head-cancel-outer
+ classes:
+ - e2e.cicir.mixins.HeadCiCancelOuterMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_ci_head_thisargs.yml b/aether-mixins-tests/src/test/resources/mixins_ci_head_thisargs.yml
new file mode 100644
index 0000000..e1a6662
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_ci_head_thisargs.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-ci-head-thisargs
+ classes:
+ - e2e.cicir.mixins.HeadThisArgsCiMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_cir_tail_null.yml b/aether-mixins-tests/src/test/resources/mixins_cir_tail_null.yml
new file mode 100644
index 0000000..f677e97
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_cir_tail_null.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-cir-tail-null
+ classes:
+ - e2e.cicir.mixins.TailCirNullMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_cir_tail_priority.yml b/aether-mixins-tests/src/test/resources/mixins_cir_tail_priority.yml
new file mode 100644
index 0000000..ebcb848
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_cir_tail_priority.yml
@@ -0,0 +1,9 @@
+version: 1
+mixins:
+ - name: e2e-cir-tail-priority
+ classes:
+ - e2e.cicir.mixins.TailCirHigh
+ - e2e.cicir.mixins.TailCirLow
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_cir_tail_static_wide.yml b/aether-mixins-tests/src/test/resources/mixins_cir_tail_static_wide.yml
new file mode 100644
index 0000000..b562b94
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_cir_tail_static_wide.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-cir-tail-static-wide
+ classes:
+ - e2e.cicir.mixins.TailCirStaticWideMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_cir_tail_sync.yml b/aether-mixins-tests/src/test/resources/mixins_cir_tail_sync.yml
new file mode 100644
index 0000000..7fd2289
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_cir_tail_sync.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-cir-tail-sync
+ classes:
+ - e2e.cicir.mixins.TailCirSyncMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_cir_tail_tryfinally.yml b/aether-mixins-tests/src/test/resources/mixins_cir_tail_tryfinally.yml
new file mode 100644
index 0000000..5a9cf23
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_cir_tail_tryfinally.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-cir-tail-tryfinally
+ classes:
+ - e2e.cicir.mixins.TailCirTryFinallyMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict
diff --git a/aether-mixins-tests/src/test/resources/mixins_invalid_thisci_on_static.yml b/aether-mixins-tests/src/test/resources/mixins_invalid_thisci_on_static.yml
new file mode 100644
index 0000000..25697a0
--- /dev/null
+++ b/aether-mixins-tests/src/test/resources/mixins_invalid_thisci_on_static.yml
@@ -0,0 +1,8 @@
+version: 1
+mixins:
+ - name: e2e-invalid-thisci-on-static
+ classes:
+ - e2e.cicir.mixins.BadThisCiStaticMixin
+runtime:
+ safe_mode: false
+ verify_frames: strict