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:
*MVP validation:
- ** * @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. + * + ** ClassSource source = ...; @@ -116,14 +109,8 @@ public Optionalresolve( 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 MapbuildWork(@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:
**
@@ -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- 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()}.
*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: + *
+ *+ *
+ * + *- Validate that a hook method descriptor is compatible with a given target method + * (instance/static semantics and positional argument compatibility).
+ *- Emit the correct operand loads (optionally {@code this}, target arguments, and a trailing + * {@code CallbackInfo}) before invoking the hook via {@code INVOKESTATIC}.
+ *Supported hook shapes
+ *+ * Let the target method descriptor be {@code (A B ... )R} and the target owner type be {@code OWNER}. + * A hook method (invoked with {@code INVOKESTATIC}) may use one of: + *
+ *+ *
+ * + *- {@link Kind#NONE} – {@code ()V}
+ *- {@link Kind#THIS} – {@code (OWNER;)V} (only for instance targets)
+ *- {@link Kind#ARGS} – {@code (A B ...)V}
+ *- {@link Kind#THIS_ARGS} – {@code (OWNER; A B ...)V} (only for instance targets)
+ *- {@link Kind#NONE_CI} – {@code (CallbackInfo)V}
+ *- {@link Kind#THIS_CI} – {@code (OWNER; CallbackInfo)V} (only for instance targets)
+ *- {@link Kind#ARGS_CI} – {@code (A B ...; CallbackInfo)V}
+ *- {@link Kind#THIS_ARGS_CI} – {@code (OWNER; A B ...; CallbackInfo)V} (only for instance targets)
+ *- {@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:
+ *+ *
+ * + * @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, "- 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.
+ *", "(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
*- *
* - *- Load the mixin class by name (using the configured {@link ClassLoader}).
- *- Collect declared methods with matching annotation: + *
- Load the mixin class by name using the configured {@link ClassLoader}.
+ *- Collect declared methods with a matching annotation: *
**
*- {@link PlannedEntry.Kind#INJECT} → {@link Inject @Inject}
*- {@link PlannedEntry.Kind#REDIRECT} → {@link Redirect @Redirect}
*- Compute each method's effective ID: - * {@code annotation.id()} if non-empty, otherwise the method's simple name.
- *- Match candidate(s) by {@code plannedEntry.id}. If the planned ID is empty, accept the single candidate - * (error on 0 or >1 to avoid ambiguity).
+ *annotation.id()if non-empty, otherwise the method's simple name. + *- Match candidate(s) against the {@link PlannedEntry#getId() planned ID}: + *
*+ *
+ *- If the planned ID is empty → accept only when there is exactly one candidate.
+ *- If the planned ID is non-empty → accept candidates whose effective ID matches exactly.
+ *- Report an error if no match or more than one match is found.
+ *- Validate the selected method: *
- *- *
*- Must be {@code static}.
- *- For INJECT (MVP): descriptor must be {@code ()V}.
- *- For REDIRECT (MVP): no deep signature checking beyond being {@code static} (compatibility with call site - * is verified by the weaver or later passes).
+ *- Must be declared {@code static}.
+ *- The method signature must be compatible with the target injection or redirect site.
+ *- For instance method redirects, the receiver type is passed as the first parameter to the hook.
*- Return {@link ResolvedHook} with internal owner name, method name, and JVM descriptor.
+ *- Return a {@link ResolvedHook} containing the internal owner name, method name, + * and the JVM descriptor of the resolved hook.
*All diagnostics are recorded in {@code problems} using the supplied {@code path}.
+ *Diagnostics
+ *+ * All diagnostics, such as missing hooks or signature mismatches, are reported to + * {@code problems} using the provided {@code path} as a human-readable context string. + * This allows callers to trace errors back to specific mixins and target methods. + *
+ * + *Thread-safety
+ *+ * This resolver is not thread-safe. A new instance should be created for each weaving run. + *
* * @author Erik Pförtner * @since 0.1.0 @@ -120,13 +135,6 @@ public Optionalresolve( 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: - *
- *
- * Implementations may enforce these requirements strictly or report them as warnings/errors in {@code problems}. + *- 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).
- *- 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* * @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. + * + *rh = resolver.resolve(mixin, entry, problems, "planner/MyMixin#0"); - * rh.ifPresent(h -> * pass to weaver * ); + * rh.ifPresent(h -> { + * // Pass to weaver + * }); * } 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 CallbackInfoReturnablecir) { + * if (input < 0) { + * cir.setReturnValue(0); + * cir.cancel(); // the original compute(...) will not run + * } + * }} + * Contract
+ *+ *
+ * + * @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- 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.
+ *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 @@ * }
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.
+ * + *
+ * {@code
+ * public void onEvent(final Cancellable callback) {
+ * if (shouldCancel()) {
+ * callback.cancel(); // signals cancellation
+ * }
+ *
+ * if (callback.isCancelled()) {
+ * System.out.println("Execution stopped due to cancellation");
+ * }
+ * }
+ * }
+ *
+ *
+ * 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}.
+ * + *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());
+ * }
+ * }
+ * }
+ *
+ *
+ * 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{@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:
* 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 MapSemantics (MVP):
+ *Semantics:
*Process (MVP):
+ *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.
+ *+ * 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.
+ *+ * 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).
+ *