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/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:

*
    *
  1. Load class bytes via {@link ClassSource}.
  2. *
  3. Scan for static methods carrying the required annotation (via {@link HookScanner}).
  4. *
  5. Filter by the planned {@code id} selection policy.
  6. - *
  7. Validate MVP constraints for the resulting candidate.
  8. + *
  9. Perform minimal kind-specific validation where applicable.
  10. *
* - *

MVP validation:

- * - * *

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 bfb702c..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
@@ -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;
 
 /**
@@ -39,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 @@ -132,6 +141,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 +170,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 +210,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 +220,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 +289,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 +307,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()); @@ -316,6 +351,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 @@ -323,7 +359,8 @@ 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, problems, internalName, sig @@ -333,21 +370,50 @@ 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. * - *

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 afd4e3a..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 @@ -1,27 +1,35 @@ 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; /** - * 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.

@@ -29,7 +37,17 @@ * @author Erik Pförtner * @since 0.1.0 */ -public final class InjectHeadAdapter extends MethodVisitor { +public final class InjectHeadAdapter extends LocalVariablesSorter { + + /** + * 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"; + + /** + * 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. @@ -37,6 +55,12 @@ public final class InjectHeadAdapter extends MethodVisitor { @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. */ @@ -68,16 +92,42 @@ 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. */ private boolean applied = false; /** - * Constructs a new adapter that injects a static {@code ()V} hook at method entry. + * 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} + * @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} @@ -87,7 +137,11 @@ public final class InjectHeadAdapter extends MethodVisitor { * @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, + @NotNull final String targetDesc, @NotNull final ResolvedHook hook, final boolean optional, @NotNull final String id, @@ -95,7 +149,11 @@ 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; this.hook = hook; this.optional = optional; this.id = id; @@ -105,17 +163,180 @@ public InjectHeadAdapter(final int api, } /** - * Emits the hook call at the beginning of the method body. + * Injects the hook call at method entry. * - *

Specifically, this calls {@code INVOKESTATIC hook.owner()/hook.name() hook.desc()} and - * then marks the enclosing weaving operation as changed.

+ *

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(); + 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, 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()); + } + return; // optional skip + } + + final Type targetRet = Type.getReturnType(this.targetDesc); + final boolean targetIsVoid = Type.VOID_TYPE.equals(targetRet); + 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 + ")."); + } + 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, kind); + } + int local = instance ? 1 : 0; + if (HookShape.passesArgs(kind)) { + 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, kind, CI_INTERNAL, cbLocal, this.methodName, true); + HookShape.emitLoadCallbackInfoIfNeeded(this, kind, cbLocal); + } else if (usesCIR) { + cbLocal = newLocal(Type.getObjectType(CIR_INTERNAL)); + HookShape.newCallbackInfoReturnableIfNeeded(this, kind, CIR_INTERNAL, cbLocal, this.methodName, true); + HookShape.emitLoadCallbackInfoReturnableIfNeeded(this, 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, 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, CIR_INTERNAL, targetRet); + HookShape.emitReturnFor(this, targetRet); + + super.visitLabel(LskipCir); + } + } + + @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 (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( + instance, this.ownerInternal, this.targetDesc, this.hook.desc(), CI_INTERNAL, CIR_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, kind); + } + int local = 1; // constructor is always instance + if (HookShape.passesArgs(kind)) { + local = HookShape.emitArgs(this, this.targetDesc, local); + } + + int ciLocal = -1; + if (usesCI) { + ciLocal = newLocal(Type.getObjectType(CI_INTERNAL)); + 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); + 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 8d638e2..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 @@ -1,10 +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.MethodVisitor; +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; @@ -14,12 +19,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()}.
  • *
@@ -37,7 +44,17 @@ * @author Erik Pförtner * @since 0.1.0 */ -public final class InjectTailAdapter extends MethodVisitor { +public final class InjectTailAdapter extends LocalVariablesSorter { + + /** + * 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"; + + /** + * 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. @@ -76,16 +93,36 @@ 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. */ 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} + * @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 +133,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, @@ -103,7 +143,10 @@ 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; this.hook = hook; this.optional = optional; this.id = id; @@ -133,13 +176,99 @@ private static boolean isReturn(final int opcode) { @Override public void visitInsn(final int opcode) { if (isReturn(opcode)) { + final boolean instance = HookShape.isInstance(this.targetAccess); + + @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( + "TAIL inject: incompatible hook signature for id=" + this.id + + " hook=" + this.hook.owner() + "." + this.hook.name() + this.hook.desc() + ); + } + 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; + } + + 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, this.targetDesc, retLocal); + } + + if (HookShape.requiresThis(kind)) { + HookShape.emitThisIfNeeded(this, kind); + } + int local = instance ? 1 : 0; + if (HookShape.passesArgs(kind)) { + local = HookShape.emitArgs(this, this.targetDesc, local); + } + + int cbLocal = -1; + if (usesCI) { + cbLocal = newLocal(Type.getObjectType(CI_INTERNAL)); + 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, kind, CIR_INTERNAL, cbLocal, /*method*/ "tail:" + this.id, /*cancellable*/ false); + + this.visitVarInsn(ALOAD, cbLocal); + HookShape.loadReturnValueFromLocal(this, this.targetDesc, retLocal); + HookShape.emitCirSetReturn(this, CIR_INTERNAL, ret); + + HookShape.emitLoadCallbackInfoReturnableIfNeeded(this, 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.visitVarInsn(ALOAD, cbLocal); + HookShape.emitCirGetReturn(this, CIR_INTERNAL, ret); + } else { + HookShape.loadReturnValueFromLocal(this, 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. * 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/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..5081683 --- /dev/null +++ b/aether-mixins-bytecode/src/main/java/de/splatgames/aether/mixins/bytecode/weaver/asm/util/HookShape.java @@ -0,0 +1,784 @@ +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: + *

+ *
    + *
  1. Validate that a hook method descriptor is compatible with a given target method + * (instance/static semantics and positional argument compatibility).
  2. + *
  3. Emit the correct operand loads (optionally {@code this}, target arguments, and a trailing + * {@code CallbackInfo}) before invoking the hook via {@code INVOKESTATIC}.
  4. + *
+ * + *

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)
  • + *
  • {@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:

+ *
    + *
  • 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, 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);
+ * }
+ *
+ * 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);
+ * }
+ *
+ * }
+ * + *

Thread-safety: This utility class is stateless and thread-safe.

+ * + * @author Erik Pförtner + * @since 0.2.0 + */ +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), + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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 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; 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 + public static Kind match(final boolean instance, + @NotNull final String ownerInternal, + @NotNull final String targetDesc, + @NotNull final String hookDesc, + @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; + } + + // (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; + } + } + + // ------------------------------------- + // CI Handling (void target only) + // ------------------------------------- + if (ciInternalName != null && targetIsVoid && isLastObjectType(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; + } + } + } + + // ------------------------------------- + // 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. + * + * @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; + } + + /** + * Determines whether the last element of the given {@link Type} array is an object type with the given internal name. + * + * @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 isLastObjectType(@NotNull final Type[] hArgs, @NotNull final String internalName) { + final Type last = hArgs[hArgs.length - 1]; + return last.getSort() == Type.OBJECT && internalName.equals(last.getInternalName()); + } + + + /** + * 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) + * @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, + @NotNull final String methodName, + final boolean cancellable + ) { + 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.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; + } + + /** + * 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) + * @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, + @NotNull final String methodName, + final boolean cancellable + ) { + 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.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; + } + + /** + * 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. + * + * @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); + } + } + + /** + * 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()} 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) { + 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)}. + * + * @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) { + 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); + } + } + + /** + * 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()}. + * + * @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(); + } + + /** + * 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-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

*
    - *
  1. Load the mixin class by name (using the configured {@link ClassLoader}).
  2. - *
  3. Collect declared methods with matching annotation: + *
  4. Load the mixin class by name using the configured {@link ClassLoader}.
  5. + *
  6. Collect declared methods with a matching annotation: *
      *
    • {@link PlannedEntry.Kind#INJECT} → {@link Inject @Inject}
    • *
    • {@link PlannedEntry.Kind#REDIRECT} → {@link Redirect @Redirect}
    • *
    *
  7. *
  8. Compute each method's effective ID: - * {@code annotation.id()} if non-empty, otherwise the method's simple name.
  9. - *
  10. 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).
  11. + * annotation.id() if non-empty, otherwise the method's simple name. + *
  12. 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.
    • + *
    + *
  13. *
  14. 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.
    • *
    *
  15. - *
  16. Return {@link ResolvedHook} with internal owner name, method name, and JVM descriptor.
  17. + *
  18. Return a {@link ResolvedHook} containing the internal owner name, method name, + * and the JVM descriptor of the resolved hook.
  19. *
* - *

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/CallbackInfo.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java new file mode 100644 index 0000000..065954c --- /dev/null +++ b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/CallbackInfo.java @@ -0,0 +1,145 @@ +package de.splatgames.aether.mixins.core.api; + +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; + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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..163b805 --- /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(I)I", at = Inject.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/Inject.java b/aether-mixins-core/src/main/java/de/splatgames/aether/mixins/core/api/Inject.java index f14f112..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 @@ -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,19 @@ boolean remap() default true; /** - * Well-known injection points. The set is intentionally minimal in the MVP. + * 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. */ 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/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/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

+ * + * + * @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); + } +} 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. */ 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

      *
        - *
      1. Flatten all {@link Refmap#getMixins()} across inputs.
      2. - *
      3. Filter by {@link SelectionOptions} (groups, requires).
      4. - *
      5. Transform {@link RefMixin} → {@link PlannedMixin}, {@link RefEntry} → {@link PlannedEntry}.
      6. - *
      7. Apply {@link ConflictResolver} to remove conflicting mixins.
      8. + *
      9. Flatten all {@link Refmap#getMixins()} across inputs into a single stream of mixins.
      10. + *
      11. Filter mixins according to {@link SelectionOptions} (e.g., groups, required flags).
      12. + *
      13. Transform structures: + *
          + *
        • {@link RefMixin} → {@link PlannedMixin}
        • + *
        • {@link RefEntry} → {@link PlannedEntry}
        • + *
        + *
      14. + *
      15. Apply {@link ConflictResolver} to remove mixins that conflict with each other.
      16. *
      * - *

      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 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/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..e6ecd65 --- /dev/null +++ b/aether-mixins-tests/src/test/java/e2e/cicir/CiCirE2E.java @@ -0,0 +1,283 @@ +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); + } + + @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); + } + + @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/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/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/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/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/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/HeadCiCancelMixin.java b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.java new file mode 100644 index 0000000..2221482 --- /dev/null +++ b/aether-mixins-tests/src/test/java/e2e/cicir/mixins/HeadCiCancelMixin.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.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]"); + ci.cancel(); + } +} 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/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/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/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/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/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/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/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/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/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/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/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.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_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_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_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_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_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_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 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_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_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_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_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 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