Using IOSystem or an extension of it breaks on Unity IL2CPP

Issue #74 new
NoTuxNoBux created an issue

In Unity and IL2CPP, configuring an IOSystem breaks at runtime because of the calls to Marshal.GetFunctionPointerForDelegate in the constructor, because IL2CPP does not know how to marshal instance methods:

NotSupportedException: IL2CPP does not support marshaling delegates that point to instance methods to native code.

Looking up some information around this, static class methods should work. Since Assimp supports taking UserData, I tried passing an instance pointer, fetching it in a static callback, and then forwarding the call to the instance method.

IL2CPP then complains that this static method must be marked with MonoPInvokeCallback. This seems impossible, however, since AssimpNet is not Unity-specific whilst this annotation is.

I managed to work around this problem by making both existing instance callbacks protected, extracting an Initialize method from the constructor, and adding a boolean that allows skipping the initialization. I can then initialize the IOSystem myself in my extension class, and set the pointers to my static class methods in Unity - which can have these attributes. This isn’t ideal, however, since I had to expose more API surface in IOStream - this could all have been transparently handled in IOStream proper, if it weren’t for the required Unity annotations and IL2CPP shenanigans.

EDIT: The same appears to apply to IOStream constructor.

Comments (5)

  1. Nicholas Woodfield repo owner

    Yeah, IL2CPP has been a problem for this library in the past. Do you have your solution available somewhere? Maybe we can work out a cleaner way to do this.

  2. NoTuxNoBux reporter

    It’s a proprietary project, but I can share some snippets that show what I’ve done now to get this to work, using IOSystem as an example.

    In AssimpNet, I modified IOSystem’s constructor as follows:

    public IOSystem(bool initialize = true)
    {
        if (initialize)
        {
            Initialize(OnAiFileOpenProc, OnAiFileCloseProc, IntPtr.Zero);
        }
    
        m_openedFiles = new Dictionary<IntPtr, IOStream>();
    }
    
    protected void Initialize(AiFileOpenProc fileOpenProc, AiFileCloseProc fileCloseProc, IntPtr userData)
    {
        AiFileIO fileIO;
        fileIO.OpenProc = Marshal.GetFunctionPointerForDelegate(fileOpenProc);
        fileIO.CloseProc = Marshal.GetFunctionPointerForDelegate(fileCloseProc);
        fileIO.UserData = userData;
    
        m_fileIOPtr = MemoryHelper.AllocateMemory(MemoryHelper.SizeOf<AiFileIO>());
        Marshal.StructureToPtr(fileIO, m_fileIOPtr, false);
    }
    

    By setting the new initialize to false in my code, I avoid IOSystem immediately calling GetFunctionPointerForDelegate with an instance method, which is what fails on IL2CPP because it does not support this.

    In my code, then, I do something like:

    public sealed class IOSystem : Assimp.IOSystem
    {
        private GCHandle? instanceHandle;
    
        public IOSystem(): base(false)
        {
            instanceHandle = GCHandle.Alloc(this);
            Initialize(OnAiFileOpenProcDispatcher, OnAiFileCloseProcDispatcher, (IntPtr)instanceHandle);
        }
    
        protected override void Dispose(bool disposing)
        {
            base.Dispose(disposing);
    
            if (disposing)
            {
                instanceHandle?.Free();
                instanceHandle = null;
            }
        }
    
        [MonoPInvokeCallback(typeof(AiFileOpenProc))]
        private static IntPtr OnAiFileOpenProcDispatcher(IntPtr fileIO, string pathToFile, string mode)
        {
            AiFileIO fileIOInstance = MemoryHelper.MarshalStructure<AiFileIO>(fileIO);
            IOSystem ioSystemInstance = ((GCHandle)fileIOInstance.UserData).Target as IOSystem;
    
            return ioSystemInstance.OnAiFileOpenProc(fileIO, pathToFile, mode);
        }
    
        [MonoPInvokeCallback(typeof(AiFileCloseProc))]
        private static void OnAiFileCloseProcDispatcher(IntPtr fileIO, IntPtr file)
        {
            AiFileIO fileIOInstance = MemoryHelper.MarshalStructure<AiFileIO>(fileIO);
            IOSystem ioSystemInstance = ((GCHandle)fileIOInstance.UserData).Target as IOSystem;
    
            ioSystemInstance.OnAiFileCloseProc(fileIO, file);
        }
    }
    

    As you can see, what I’m doing is using static methods instead of instance methods to pass to Assimp, which is fine for IL2CPP. Because I still want to have access to the instance, I use Assimp’s UserData to marshall the pointer back and forth.

    I originally wanted to place this code inside AssimpNet itself, which would make this behaviour transparent and not require application changes, but I can’t because of the (unfortunately required) MonoPInvokeCallback attribute, which seems to be Unity-specific and not available in the .NET context of AssimpNet.

    I’ve used the same approach for IOStream.

  3. NoTuxNoBux reporter

    I’ve created PR #5 with the AssimpNet-specific bits of this that allow overriding the callbacks passed to Assimp from Unity. The Unity bits are mostly out of scope for AssimpNet itself, though they could be added to the wiki, README, or Unity sample code, as they are necessary to work with custom IOSystems in Unity when using IL2CPP.

  4. Log in to comment