Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Conversation

aldelaro5
Copy link
Contributor

@aldelaro5 aldelaro5 commented Sep 30, 2024

Description

This pr supercedes #988 by redesigning the api around the idea of having a single plugin loading system, but divided up into multiple phases.

The way it works is there's no more different plugin types, only a single plugin type exists that can do everything in theory. In practice however, not everything can be doable (for example, a patcher can't reference game assemblies) so to accommodate this, every plugins now requires a phase attribute that will determine when the Load method will be invoked and the plugin loaded.

A phase is defined as a simple string. BepInEx would define standard ones, but in theory, a plugin could create phases of their own. It only defines a moment in time that can be listened to via a global event. The caretaker of that event is a new class called PhaseManager. It allows anyone to listen to any phase or to start one.

By itself, nothing happens, but where the power of this new API model comes in is when the plugin loading system gets involved. There is now a single loading system called PluginManager and all it does is listen to every phase. On every phases, it will do 2 important things:

  • Gather all the providers that were registered on the phase prior and gets the load contexts that they provide
  • Load all applicable plugins from these load contexts

In order for BepInEx to bootstrap everything on the first phase, a default provider is registered first which will always provide load contexts from the plugins folder before every phases.

This effectively isolates the entire plugin loading system. All BepInEx needs to do is to start the phases at the appropriate moments depending on the runtime and PluginManager will take care of the rest.

As a side effect of this, it also means there's no specialised APIs offered to plugins: they just have a Load, Info, Config and Logger. All apis that BepInEx offers will be done in some other ways such as calbacks or public data structures. A notable improvement to this is AssemblyPatcher: the api is no longer attributes based. Plugins now creates their PatchDefinition themselves on load which contains a calback that performs the patching. This greatly simplifies the api because it eliminates complex attributes and methodInfo parsing.

Currently, this pr defines 4 phases:

  • Entrypoint: This is the earliest phase where only the critical systems such as logging are initialised. For IL2CPP, this notably happens before interop assemblies were generated
  • Before game assemblies load: This is effectively the phase patchers will use because it defines the moment right before game assemblies are loaded so it is the chances of patchers to do their thing.
  • After game assemblies load: This is a phase that occurs once game assemblies are loaded, but it's not the last phase BepInEx defines. This is because this phase is meant as a way to provide other plugins or to have plugins that must runs before any standard game mod, but with the ability to reference game assemblies safely
  • Game Initialised: The last phase where most plugins will load and can contain game mod. Currently, this phase occurs right after the previous one.

Motivation and Context

This has several implications:

  • There is now no need to have multiple plugins folder
  • We treat the plugins loader as an intrinsic feature
  • It allows to have more moments to load things including even earlier than patchers
  • Only a single plugin type exists which simplifies the API

The overall effect is the APi becomes MUCH simpler and it becomes more malleable.

How Has This Been Tested?

I tested on new/old mono (including unity 4.x) and il2cpp and everything seemed to work.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.

Possible discussions

I am opening this as a draft because while I think this pr includes most of the foundation we need, there's some open questions that would need to be further discussed:

  • I attempted to move unity mono's chainloader logic into a plugin and not only I found it was surprisingly easy, it actually improves the handoffs between the preloader and the chainloader because they are now completely dcoupled. However, I can't do this yet for il2cpp because it needs to be split into 2 assemblies (preloader and the rest). This might be worth to do, but I don't think it needs to happen in this pr.
  • Wouldn't it be possible to move preloader logic into plugins? I checked and I felt it would be difficult because there doesn't seem to have guarantees about having basic systems like logging to work
  • Currently, phases are C# events, but it might not be the best. What if a plugin needs to be more towards the start or the end of the phase? Should we have a very simplistic system where a calback can be registered in 3 lanes? (start, default and end)
  • It was found for various reasons that an api call requires to know the plugin that is doing it. I think we should consider some ways to either track what plugins is going to perform calls or not make it a choice (extension method?). Having to send "this" adds unnecessary boilerplate that I think we should consider not having.
  • This new api model throws the concept of "chainloader" completely on its head. A chainloader no longer actually does any loading, but we still want a lot of the logic shared for all runtimes and have runtimes be able to customize it. A redesign of this area should be considered
  • Do we expect the plugins discovery in the plugins folder to be fixed at the entrypoint time? If yes, we can cache things more aggressively and simplify the default provider
  • About the phase attribute, should it default to GameInitialised? I think there's value to want this. There's also to consider if it's well positioned: should it be its own attribute or part of metadata? I don't think it should be, but it's an open question

BepInEx.Core/Bootstrap/PluginManager.cs Outdated Show resolved Hide resolved
Logger.Log(LogLevel.Message, "Started phase " + phase);
CurrentPhase = phase;
OnPhaseStarted?.Invoke(phase);
Logger.Log(LogLevel.Message, "Ended phase " + phase);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logging overall seems pretty excessive or at least at wrong levels. Something to look into later once things are getting finalized.

This seems like a good place to add a StopWatch and log how long a phase took.

BepInEx.Core/Contract/BepInPhases.cs Outdated Show resolved Hide resolved
private IList<IPluginLoadContext> GetLoadContexts()
{
var loadContexts = new List<IPluginLoadContext>();
foreach (var dll in Directory.GetFiles(Path.GetFullPath(Paths.PluginPath), "*.dll", SearchOption.AllDirectories))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about caching all of these contexts (as cached contexts) instead of reiterating over the files on every phase? Or do we expect dll files to be dropped at runtime? Wouldn't this add up across different providers having to reload on every phase?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a discussion item in the OP because it depends if we expect the discovery to not change at entrypoint time

Comment on lines 83 to +84
[AttributeUsage(AttributeTargets.Class)]
public class BepInPluginProvider : BepInPlugin
public class BepInPhaseAttribute : Attribute
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the phase attribute supposed to be unique per plugin? If yes, it would make sense to combine it with BepInMetadataAttribute to ensure people don't forget it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Along with the general comment, added a discussion item. I feel like it shouldn't be part of metadata, but I like the idea to default it to GameInitialised

@ManlyMarco
Copy link
Member

Conceptually it seems fine but I would have to try actually using it to tell for sure. Main worry is the phases being confusing, they need to be easy to figure out even without reading docs. Maybe move the phase to the metadata attribute and give it a default value of GameInitialised.

@aldelaro5
Copy link
Contributor Author

For the sake of discussions, I provided some samples of trivial plugins: a patcher, a game plugin and a provider.

Here's the regular game plugin:

using BepInEx;

namespace TestPlugin;

[BepInMetadata("testPlugin", "test plugin", "1.0.0")]
[BepInPhase(BepInPhases.GameInitialised)]
public class TestPlugin : Plugin
{
    public override void Load()
    {
        Logger.LogInfo("Inside TestPlugin!");
    }
}

To note, this type of plugin would be declared the exact same way no matter the runtime here. Unity plugins no longer are GameObjects, but nothing stops a plugin from using the extension method for this: AddUnityComponent. For example this.AddUnityComponent<MyCustomComponent>();. The phase is the last of the predefined one because it guarantees all initial providers ran and the game is ready.

Next, here's a patcher plugin:

using BepInEx;
using BepInEx.Preloader.Core.Patching;
using Mono.Cecil;

namespace TestPatcher;

[BepInMetadata("TestPatchr", "TestPatcher", "1.0.0")]
[BepInPhase(BepInPhases.BeforeGameAssembliesLoaded)]
public class TestPatcher : Plugin
{
    public override void Load()
    {
        AssemblyPatcher.Instance
                       .AddDefinition(new("Assembly-CSharp.dll", "MainManager", this,
                                          (_, type, _) => PatchAssembly(type)));
    }

    private bool PatchAssembly(TypeDefinition type)
    {
        // Patching logic
        return true;
    }
}

The important part is the phase: it's the second of the predefined ones which is the last before game assemblies loading. This means this plugin won't be able to reference any game assemblies, but that would allow to prepatch them.

The way to declare the patching is changed. We now have an API exposed by AssemblyPatcher and every patcher would call it to declare their patches. The rest is essentially the same: use Cecil to doe the patching. As for when the patching will occur, it is managed by the runtime's preloader still on the same phase, but after the phase fully started. This means every patcher plugins declared their patcher by this point and the AssemblyPatcher simply calls all of them to patch everything. It's essentially the last thing the preloader would do before the chainloader starts.

Finally, here's a plugin providing another (I made a simple one here where I hardcoded a path, but you can imagine this working with zip or from a network):

using System.Collections.Generic;
using System.IO;
using BepInEx;

namespace TestProvider;

[BepInMetadata("testProvider", "test plugin provider", "1.0.0")]
[BepInPhase(BepInPhases.AfterGameAssembliesLoaded)]
public class TestProvider : Plugin
{
    public override void Load()
    {
        Logger.LogInfo("Starting custom plugin provider");
        PluginManager.Instance.Providers.Add(Info, GetLoadContexts);
        Logger.LogInfo("Added a provider");
    }

    private IList<IPluginLoadContext> GetLoadContexts()
    {
        return [new TestPluginLoadContext()];
    }
}

internal class TestPluginLoadContext : IPluginLoadContext
{
    public string AssemblyIdentifier => "TestPlugin";
    public string AssemblyHash => File.GetLastWriteTimeUtc(Path).ToString("0");

    private const string Path = @"C:\Users\aldelaro5\Documents\bepinex v7 api samples\test\TestPlugin.dll";

    public byte[] GetAssemblyData()
    {
        return File.ReadAllBytes(Path);
    }

    public byte[] GetAssemblySymbolsData()
    {
        return File.ReadAllBytes(System.IO.Path.ChangeExtension(Path, "pdb"));
    }

    public byte[] GetFile(string relativePath)
    {
        return File.ReadAllBytes(System.IO.Path.Combine(Utility.ParentDirectory(Path), relativePath));
    }
}

There's a bit more here so:

  • TestProvider is the plugin. Notice the phase is the first phase after assemblies load, but it's not GameInitialised. This is because this is what allows the plugin to add a provider for the next phase (this pr currently assumes each provider only provides to the next phase, but I am unsure if this needs discussions). It adds a provider to the current list which is declared as itself doing the providing and a method that will actually do it.
  • GetLoadContexts is a method in the plugin that is responsible for the providing. Here, I just provide one plugin so it's very trivial, but it would support to provide multiple ones and to dynamically build a list based on various things (it could even leverage game logic to determine this list through something like a custom UI).
  • TestPluginLoadContext is a singular load context which is what a provider needs to give to BepinEx so the plugin can be loaded. Here, I just have a simple plugin with a hardcoded path so the implementations is fairly simple, but the things to note is a provider can give anything it wants as long as it provides the assembly, its symbols (if they exist) and any files requested by the plugin itself. The identifier and hash are mainly for caching purposes where the identifier is unique per assembly in a provider (there's just one here) and the hash is unique per version, but it is optional (if it's null, the caching system assumes it needs to be rechecked all the time since it won't be able to tell that the assembly changed or not). The GetFile part is notable because it implies plugins that could be provided by another plugin can't use things like our plugin path because they might not reside there anymore. The api to make this work is the plugin's Info now has a member called LoadContext which is the context that provided the plugin and you can call GetFile on it to get a file from a relative path

Hopefully this helps. There's just a lot of open questions around the redesign so I had requests to give some samples.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.