Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Calls to static @Shadow methods do not get remapped in dev environment #431

Open
PhiProven opened this issue Aug 12, 2020 · 6 comments
Open

Comments

@PhiProven
Copy link

If an obfuscated mod dependency contains a static @Shadow method that gets unmapped in dev environment, then calls to it from other mixed in methods won't get remapped and will still reference the obfuscated name. The shadow itself will however apply correctly in the deobfuscated environment, causing NoSuchMethodErrors once some method tries to access it with the original obfuscated name.

In the following I will present a quite related but simpler version of this general bug that relies on the aliases property of @Shadow in order to force some sort of remapping. Hence the following does not need an obfuscated dependency and in particular does not rely on external obfuscation. Nevertheless, the main motivation for this is still with obfuscated mod dependencies in mind.

Consider the following mixin

@Mixin(TitleScreen.class)
public abstract class ExampleMixin {
	@Shadow(aliases = "loadTexturesAsync")
	public static CompletableFuture<Void> loadTexturesAsync_test(final TextureManager textureManager, final Executor executor)
	{
		return null;
	}

	@Inject(at = @At("HEAD"), method = "init()V")
	private void init(CallbackInfo info)
	{
		loadTexturesAsync_test(null, null);
	}
}

which throws the following runtime error, showing that the call to loadTexturesAsync_test indeed does not get remapped to loadTexturesAsync whereas the shadow itself does apply without complaints:

java.lang.NoSuchMethodError: net.minecraft.client.gui.screen.TitleScreen.loadTexturesAsync_test(Lnet/minecraft/client/texture/TextureManager;Ljava/util/concurrent/Executor;)Ljava/util/concurrent/CompletableFuture;
    at net.minecraft.client.gui.screen.TitleScreen.handler$zza000$init(TitleScreen.java:526)
    at net.minecraft.client.gui.screen.TitleScreen.init(TitleScreen.java)
...

Looking through the code there are two places where method bodies get remapped. The actual remapping of the shadow itself is done elsewhere (during shadow discovery) and attaches the necessary metadata to the shadow methods which is employed by these two remapping phases.
The first one is located in org.spongepowered.asm.mixin.transformer.MixinPreProcessorStandard and is applied during the pre processing phase:

protected void transformMethod(MethodInsnNode methodNode) {
	Activity activity = this.activities.begin("%s::%s%s", methodNode.owner, methodNode.name, methodNode.desc);
	Section metaTimer = this.profiler.begin("meta");
	ClassInfo owner = ClassInfo.forDescriptor(methodNode.owner, TypeLookup.DECLARED_TYPE);
	if (owner == null) {
		throw new ClassMetadataNotFoundException(methodNode.owner.replace('/', '.'));
	}

	Method method = owner.findMethodInHierarchy(methodNode, SearchType.ALL_CLASSES, ClassInfo.INCLUDE_PRIVATE);
	metaTimer.end();
	
	if (method != null && method.isRenamed()) {
		methodNode.name = method.getName();
	}
	activity.end();
}

As can be seen here, owner.findMethodInHierarchy(methodNode, SearchType.ALL_CLASSES, ClassInfo.INCLUDE_PRIVATE); only includes private methods but not static methods. Hence this remapping phase will only remap calls to non-static shadows, but not to static ones, so in particular this does not handle the call to the static shadow in the example.

The second remapping is loacted in org.spongepowered.asm.mixin.transformer.MixinTargetContext and is applied during the post processing phase. It is responsible for re-targeting method calls to the mixin class and point them to the target class. But it also handles renames for synthetic and conformal methods, i.e., renames introduced by the mixin processor itself due to static @Accessors and @Uniques:

/**
 * Transforms a method invocation/reference in the method. Updates static
 * and dynamic bindings.
 * 
 * @param method Method being processed
 * @param iter Insn interator
 * @param methodRef Method reference to transform
 */
private void transformMethodRef(MethodNode method, Iterator<AbstractInsnNode> iter, MemberRef methodRef) {
	this.transformDescriptor(methodRef);
	
	if (methodRef.getOwner().equals(this.getClassRef())) {
		methodRef.setOwner(this.getTarget().getClassRef());
		Method md = this.getClassInfo().findMethod(methodRef.getName(), methodRef.getDesc(), ClassInfo.INCLUDE_ALL);
		if (md != null && md.isRenamed() && md.getOriginalName().equals(methodRef.getName()) && (md.isSynthetic() || md.isConformed())) {
			methodRef.setName(md.getName());
		}
		this.upgradeMethodRef(method, methodRef, md);
	} else if (this.innerClasses.containsKey(methodRef.getOwner())) {
		methodRef.setOwner(this.innerClasses.get(methodRef.getOwner()));
		methodRef.setDesc(this.transformMethodDescriptor(methodRef.getDesc()));
	} else if (this.detachedSuper || this.inheritsFromMixin) {
		if (methodRef.getOpcode() == Opcodes.INVOKESPECIAL) {
			this.updateStaticBinding(method, methodRef);
		} else if (methodRef.getOpcode() == Opcodes.INVOKEVIRTUAL && ClassInfo.forName(methodRef.getOwner()).isMixin()) {
			this.updateDynamicBinding(method, methodRef);
		}
	}

}

In this case the condition if (md != null && md.isRenamed() && md.getOriginalName().equals(methodRef.getName()) && (md.isSynthetic() || md.isConformed())) does not handle the call to the static shadow in the example either.

So in the end, the reference to the static shadow will not be remapped by either method, hence the original obfuscated name is retained as seen in the exception.

There was an attempt to fix this on Fabric's mixin fork by including static methods in the pre processor remapping phase, see FabricMC@ec491e3. However, this caused issue FabricMC#28 and was reverted again lately. The issue with this naive approach is that there is a clash of concepts for accessor mixins containing static @Accessors:

  • On the one hand you have the bridge method that gets mixed into the target class and gets a conformal name, e.g. getActiveProgram_$md$2f8dd9$0 in the example of the bug report.
  • On the other hand there is the synthetic method in the resulting accessor interface which calls the bridge method and retains its name, e.g., getActiveProgram in the example.

This is special to accessor mixins since the resulting interface can be loaded like a usual class, in contrast to all other mixins. The issue is now that these two concepts, i.e., the synthetic method in the resulting interface retaining its name and the conformal bridge method mixed into the target class, are internally represented by the same MethodRef object. So the naive approach of just adding static methods to the lookup had the issue that it did find this ambiguous method getActiveProgram and erronously renamed it to its conformal counterpart getActiveProgram_$md$2f8dd9$0.
One way to avoid this issue would be to include static methods in the lookup but explicitly exclude conformed methods, so in particular this conformal bridge method would be excluded. However, this would also cause calls to other conformed methods, i.e., @Unique methods, not to be renamed in this phase and defer the renaming to the post processor remapping phase. Note that in fact calls to static @Unique methods are currently already remapped in the post processor phase due to the same bug of not including static methods.
Whether or not this would be desired I cannot tell, since I don't know the reasoning behind splitting the remapping into the two phases, so I don't know which kind of remapping should belong to which phase.
Another approach would be to somehow disambiguate the two concepts or mark the conformal counterpart of static accessors, so it could be explicitly excluded.

Furthermore, note that some similar scenario exists for static shadowed fields, as MixinPreProcessorStandard.transformField(...) does not include static fields either. However, in this case its post processor counterpart MixinTargetContext.transformFieldRef(...) contains the clause

if (field != null && field.isRenamed() && field.getOriginalName().equals(fieldRef.getName()) && field.isStatic()) {
	fieldRef.setName(field.getName());
}

which does handle these shadowed static fields, intentional or not. So shadowed static fields do actually not cause a NoSuchFieldException.

Finally, let me point out also another related issue. Note that the pre processor remapping MixinPreProcessorStandard.transform(MixinTargetContext context) only processes MethodInsnNode and FieldInsnNode, whereas the post processor remapping MixinTargetContext.transformMethod(MethodNode method) furthermore processes TypeInsnNode, LdcInsnNode and InvokeDynamicInsnNode. So in particular the post processor remapping does process Java's method references, i.e., Clas::method, whereas the pre processor remapping does not. Hence such method references to obfuscated shadows will not be unmapped, regardless of whether they are static or not, simply because they are not processed in the pre processor phase and they are not conformed methods, hence not handled in the post prcocessor phase either.
This issue is in fact already reported as #406.

Best,
PhiPro

@Mumfrey
Copy link
Member

Mumfrey commented Aug 12, 2020

Thanks for the detailed write up. I now understand why I was getting spurious reports of calls to undefined static accessor methods pertaining to fabric's fork. I will look into this.

@Chocohead
Copy link

After throwing a breakpoint on this line in MixinTargetContext:

if (md != null && md.isRenamed() && md.getOriginalName().equals(methodRef.getName()) && (md.isSynthetic() || md.isConformed())) {

It seems like the only conditions where md is present, renamed, and has the same original name as methodRef but isn't synthetic or conformed is when a static shadow method has changed name. This process could come in from refmap remapping, removing the prefix, or aliasing, any one of the three will cause the same NoSuchMethodError crash. The moral of the story seems to be that removing the final check that it is synthetic or conformed (i.e. to just take that a method being renamed at that point needs to be remapped) resolves all the issues with static shadowed methods. I think this might also accidentally fix #406 as any method references will be correctly remapped by MixinTargetContext#transformInvokeDynamicNode.

Not sure if it's really intensional that it gets to that point before it renaming, but for static fields at least it does so as a fix it is consistent with that at least. For reference the set of Mixins I ran with are here. It jumps a bit between the Mixin classes as an accident from trying to cover all the situations when ClassInfo$Method#renameTo is used, don't think I missed anything but can happily add any other scenarios if I have.

@PhiProven
Copy link
Author

The moral of the story seems to be that removing the final check that it is synthetic or conformed (...) resolves all the issues with static shadowed methods.

Pretty sure that simply removing the check will incur the same issue that Fabric's workaround had, as discussed above (FabricMC#28).

@Chocohead
Copy link

Perhaps it might, let's export the results with -Dmixin.debug.export=true and compare:

Starting with the interface TestMixinA, a combination of @Accessors and @Invokers, using 0.8.1 the result is

@Mixin({ TestTarget.class })
public interface TestMixinA {
    @Accessor
    String getField();
    
    @Accessor
    void setField(final String p0);
    
    @Invoker
    String callViewField();
    
    @Invoker
    @MixinProxy(sessionId = "45e702f8-fb08-4973-a363-caa26a9bc918")
    static /* synthetic */ void callLoad() {
        load();
    }
    
    @Invoker("<init>")
    @MixinProxy(sessionId = "45e702f8-fb08-4973-a363-caa26a9bc918")
    static /* synthetic */ TestTarget make() {
        return TestTarget.make_$md$9bc918$1();
    }
}

All looks well, let's contrast this to Fabric's 0.8.0 fork:

@Mixin({ TestTarget.class })
public interface TestMixinA {
    @Accessor
    String getField();
    
    @Accessor
    void setField(final String p0);
    
    @Invoker
    String callViewField();
    
    @Invoker
    @MixinProxy(sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    static /* synthetic */ void callLoad() {
        load();
    }
    
    @Invoker("<init>")
    @MixinProxy(sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    static /* synthetic */ TestTarget make() {
        return TestTarget.make_$md$a327a9$1();
    }
}

Essentially the same, allowing for mangling difference. TestMixinB, TestMixinC and TestMixinZ are all normal Mixin classes, so we'll go straight to the targets.

TestMixinZ targets TestParent, an abstract class with just a squish method. Looking at 0.8.1:

public abstract class TestParent {
    public TestParent() {
        super();
    }
    
    void squish() {
    }
    
    @Unique
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinZ", priority = 1000, sessionId = "45e702f8-fb08-4973-a363-caa26a9bc918")
    protected void gain() {
        System.out.println("Root gain");
    }
}

And Fabric's 0.8.0 fork:

public abstract class TestParent {
    public TestParent() {
        super();
    }
    
    void squish() {
    }
    
    @Unique
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinZ", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    protected void gain() {
        System.out.println("Root gain");
    }
}

Completely the same, not much to see here either. Now let's look at the real meat, the TestTarget class.

Mixin 0.8.1
public class TestTarget extends TestParent implements TestMixinA, Iterable {
    static boolean lock;
    private String field;
    
    public TestTarget() {
        super();
    }
    
    String viewField() {
        this.handler$zdg000$inject(new CallbackInfoReturnable("viewField", false));
        return this.field;
    }
    
    public static void load() {
        handler$zdh000$check(new CallbackInfo("load", false));
    }
    
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinB", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    private void handler$zdg000$inject(final CallbackInfoReturnable call) {
        shadow£$load();
    }
    
    @Unique
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinB", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    private void gain() {
        this.iterator();
        this.field = this.viewField();
    }
    
    @Overwrite
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinB", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    @Override
    void squish() {
        this.gain();
    }
    
    @MixinRenamed(originalName = "inter£$iterator", isInterfaceMember = true)
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinB", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    @Override
    public Iterator iterator() {
        return Collections.<Object>emptyIterator();
    }
    
    @Unique
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinC", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    protected void md59d823$gain$2() {
        super.gain();
        System.out.println("Child gain");
        this.squish();
    }
    
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinC", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    private static void handler$zdh000$check(final CallbackInfo call) {
        if (TestTarget.lock) {
            return;
        }
        System.out.println("Testing application");
        TestTarget.lock = true;
        TestMixinA.make().md59d823$gain$2();
        TestTarget.lock = false;
        System.out.println("Applied!");
    }
    
    @Overwrite
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinC", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    public static void go() {
        TestMixinA.callLoad();
    }
    
    @Accessor(target = "field:Ljava/lang/String;")
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    @Override
    public /* synthetic */ String getField() {
        return this.field;
    }
    
    @Accessor(target = "field:Ljava/lang/String;")
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    @Override
    public /* synthetic */ void setField(final String field) {
        this.field = field;
    }
    
    @Invoker
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    @Override
    public /* synthetic */ String callViewField() {
        return this.viewField();
    }
    
    @Invoker
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    public static /* synthetic */ void callLoad_$md$59d823$0() {
        load();
    }
    
    @Invoker("<init>")
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "75a46860-cda2-41dd-abc0-fada0259d823")
    public static /* synthetic */ TestTarget make_$md$59d823$1() {
        return new TestTarget();
    }
}
Fabric's 0.8.0 fork
public class TestTarget extends TestParent implements TestMixinA, Iterable {
    static boolean lock;
    private String field;
    
    public TestTarget() {
        super();
    }
    
    String viewField() {
        this.handler$zzi000$inject(new CallbackInfoReturnable("viewField", false));
        return this.field;
    }
    
    public static void load() {
        handler$zzj000$check(new CallbackInfo("load", false));
    }
    
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinB", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    private void handler$zzi000$inject(final CallbackInfoReturnable call) {
        load();
    }
    
    @Unique
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinB", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    private void gain() {
        this.iterator();
        this.field = this.viewField();
    }
    
    @Overwrite
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinB", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    @Override
    void squish() {
        this.gain();
    }
    
    @MixinRenamed(originalName = "inter£$iterator", isInterfaceMember = true)
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinB", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    @Override
    public Iterator iterator() {
        return Collections.<Object>emptyIterator();
    }
    
    @Unique
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinC", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    protected void mda327a9$gain$2() {
        super.gain();
        System.out.println("Child gain");
        this.squish();
    }
    
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinC", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    private static void handler$zzj000$check(final CallbackInfo call) {
        if (TestTarget.lock) {
            return;
        }
        System.out.println("Testing application");
        TestTarget.lock = true;
        TestMixinA.make_$md$a327a9$1().mda327a9$gain$2();
        TestTarget.lock = false;
        System.out.println("Applied!");
    }
    
    @Overwrite
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinC", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    public static void go() {
        TestMixinA.callLoad_$md$a327a9$0();
    }
    
    @Accessor(target = "field:Ljava/lang/String;")
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    @Override
    public /* synthetic */ String getField() {
        return this.field;
    }
    
    @Accessor(target = "field:Ljava/lang/String;")
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    @Override
    public /* synthetic */ void setField(final String field) {
        this.field = field;
    }
    
    @Invoker
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    @Override
    public /* synthetic */ String callViewField() {
        return this.viewField();
    }
    
    @Invoker
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    public static /* synthetic */ void callLoad_$md$a327a9$0() {
        load();
    }
    
    @Invoker("<init>")
    @MixinMerged(mixin = "com.chocohead.test.mixin.TestMixinA", priority = 1000, sessionId = "7956230c-1a1c-4882-be6c-df13aba327a9")
    public static /* synthetic */ TestTarget make_$md$a327a9$1() {
        return new TestTarget();
    }
}

Now they're both pretty tall classes once Mixin is finished with them, so let's summarise the relevant changes to save playing spot-the-difference.

private void handler$zdg000$inject(final CallbackInfoReturnable call) {
-   shadow£$load();
+   load();
}

private static void handler$zdh000$check(final CallbackInfo call) {
    if (TestTarget.lock) {
        return;
    }
    System.out.println("Testing application");
    TestTarget.lock = true;
-   TestMixinA.make().md59d823$gain$2();
+   TestMixinA.make_$md$a327a9$1().mda327a9$gain$2();
    TestTarget.lock = false;
    System.out.println("Applied!");
}

public static void go() {
-   TestMixinA.callLoad();
+   TestMixinA.callLoad_$md$a327a9$0();
}

So now we can see the effects of Fabric's changes. Because it finds the shadow methods in MixinPreProcessorStandard#transformMemberReference, it notices the ones which are renamed (not that the renames are conformed), then remaps the reference. Note how as well as breaking the references to TestMixinA, it does fix the one within the target class, which is why the change was originally made in Fabric.

Neither Mixin version successfully applies the set of Mixins properly, stock Mixin fails to fix the static shadow whilst Fabric's does the static shadow correctly but fails with the static invokers. The question then falls to why my suggested fix wouldn't do the same as Fabric's. The key difference is Fabric's tries to remap all static references whilst MixinTargetContext#transformMethodRef checks that the method belongs to the Mixin class being remapped first. Hence when break pointing the interface's methods never came up, as it never tried to remap them in the first place.

Potentially this means if you extended a Mixin then tried to use a static shadow in the child Mixin it would fail to remap too; it seems there are attempts to tape up similar sounding situations just below:

if (methodRef.getOpcode() == Opcodes.INVOKESPECIAL) {
this.updateStaticBinding(method, methodRef);
} else if (methodRef.getOpcode() == Opcodes.INVOKEVIRTUAL && ClassInfo.forName(methodRef.getOwner()).isMixin()) {
this.updateDynamicBinding(method, methodRef);
}

Unfortunately as it stands these are insufficient, as they don't cover the case when methodRef.getOpcode() == Opcodes.INVOKESTATIC (which begs the question what does calling a super Mixin's static method do now?). Fixing that is a tad more complicated as the owner would probably need to be fixed to match what the super Mixin was targetting and the name corrected for whatever the super Mixin had it renamed as. Possibly the owner could be set to the child Mixin's target and the JVM try infer the true owner, not ideal as solutions go. Either way, it's not a new regression as far as I can see so just becomes another TODO.

Chocohead added a commit to Chocohead/Mixin that referenced this issue Oct 3, 2020
Chocohead added a commit to Chocohead/Mixin that referenced this issue Oct 3, 2020
modmuss50 pushed a commit to FabricMC/Mixin that referenced this issue Oct 5, 2020
@LogicFan
Copy link

I've experienced the same problem and can verify the PR FabricMC#39 solve the problem. Should we move this PR upstream?

@FrootsieLoopsie
Copy link

I can confirm that this bug still occurs with fabric, with 'final' attributes that have pointers, like a List.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants