You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

13 KiB

uid title
articles.start.dev Making your own mod

Making a mod

Overview

What follows is a very barebones, and frankly not very useful plugin class, even as a starting point, but it should be enough to give a decent idea of how to do quick upgrades of existing mods for those who want to.

[!code-csPlugin.cs]

There are basically 4 major concepts here:

  1. xref:IPA.Logging.Logger, the logging system.
  2. xref:IPA.PluginAttribute, which declares that this class is a plugin and how it should behave.
  3. xref:IPA.InitAttribute, which declares the constructor (and optionally other methods) as being used for initialization.
  4. The lifecycle event attributes xref:IPA.OnStartAttribute and xref:IPA.OnExitAttribute.

I reccommend you read the docs for each of those to get an idea for what they do.

It is worth noting that this example is of a mod that cannot be enabled and disabled at runtime, as marked by RuntimeOptions.SingleStartInit.

What can be changed

Before we go adding more functionality, its worth mentioning that that is not the only way to have a plugin set up.

For starters, we can add another method marked [Init], and it will be called after the constructor, with the same injected parameters, if those are applicable.

[!code-csPlugin.cs#Init(Logger)]

If you only had a method marked [Init], and no constructors marked [Init], then the plugin type must expose a public default constructor. If multiple constructors are marked [Init], only the one with the most parameters will be called.

You may also mark as many methods as you wish with [Init] and all of them will be called, in no well-defined order on initialization. The same is true for [OnStart] and [OnExit], respectively.

From Scratch

If you are starting from scratch, you will need one other thing to get your plugin up and running: a manifest.

A basic manifest for that might look a little like this:

[!code-jsonmanifest.json]

There is a lot going on there, but most of it should be decently obvious. Among the things that aren't immediately obvious, are

  • id: This represents a unique identifier for the mod, for use by package managers such as BeatMods. It may be null if the mod chooses not to support those.
  • features: Don't worry about this for now, this is a not-very-simple thing that will be touched on later.

In addition, there are a few gatchas with it:

  • description: This can be either a string or an array representing different lines. Markdown formatting is permitted.
  • gameVersion: This should match exactly with the application version of the game being targeted. While this is not enforced by BSIPA, mod repositories like BeatMods may require it match, and it is good practice regardless.
  • version: This must be a valid SemVer version number for your mod.

In order for your plugin to load, the manifest must be embedded into the plugin DLL as an embedded resource. This can be set in the Visual Studio file properties panel under Build Action, or in the .csproj like so:

[!code-xmlDemo.csproj#manifest]

At this point, if the main plugin source file and the manifest are in the same source location, and the plugin class is using the project's default namespace, the plugin will load just fine. However, this is somewhat difficult both to explain and verify, so I recommend you use the the misc.plugin-hint field in your manifest. It can be used like so:

[!code-jsonmanifest.json#misc.plugin-hint]

With this, you can set plugin-hint to the full typename of your plugin type, and it will correctly load. This is a hint though, and will also try it as a namespace if it fails to find the plugin type. If that fails, it will then fall back to using the manifest's embedded namespace.

A less painful description

If you want to have a relatively long or well-formatted description for your mod, it may start to become painful to embed it in a list of JSON strings in the manifest. Luckily, there is a way to handle this.

The first step is to create another embedded file, but this time it should be a Markdown file, perhaps description.md. It may contain something like this:

[!code-markdowndescription.md]

Then, in your manifest description, have the first line be something look like this, but replacing Demo.description.md with the fully namespaced name of the resource:

[!code-jsonmanifest.json#description]

Now, when loaded into memory, if anything reads your description metadata, they get the content of that file instead of the content of the manifest key.

Configuring your plugin

Something that many plugins want and need is configuration. Fortunately, BSIPA provides a fairly powerful configuration system out of the box. To start using it, first create a config class of some kind. Lets take a look at a fairly simple example of this:

[!code-csPluginConfig.cs#basic]

Notice how the class is both marked public and is not marked sealed. For the moment, both of these are necessary. Also notice that all of the members are properties. While this doesn't change much now, it will be significant in the near future.

Now, how do we get this object off of disk? Simple. Back in your plugin class, change your [Init] constructor to look like this:

[!code-csPlugin.cs#config-init]

For this to compile, though, we will need to add a few usings:

[!code-csPlugin.cs#usings]

With just this, you have your config automatically loading from disk! It's even reloaded when it gets changed mid-game! You can now access it from anywhere by simply accessing PluginConfig.Instance. Make sure you don't accidentally reassign this though, as then you will loose your only interaction with the user's preferences.

By default, it will be named the same as is in your plugin's manifest's name field, and will use the built-in json provider. This means that the file that will be loaded from will be UserData/Demo Plugin.json for our demo plugin. You can, however, control both of those by applying attributes to the xref:IPA.Config.Config parameter, namely xref:IPA.Config.Config.NameAttribute to control the name, and xref:IPA.Config.Config.PreferAttribute to control the type. If the type preferences aren't registered though, it will just fall back to JSON.

The config's behaviour can be found either later here, or in the remarks section of xref:IPA.Config.Stores.GeneratedStore.Generated``1(IPA.Config.Config,System.Boolean).

At this point, your main plugin file should look something like this:

[!code-csPlugin.cs]


But what about more complex types than just int and float? What if you want sub-objects?

Those are supported natively, and so are very easy to set up. We just add this to the config class:

[!code-csPluginConfig.cs#sub-basic]

Now this object will be automatically read from disk too.

But there is one caveat to this: because SubThingsObject is a reference type, SubThings can be null.

This is often undesireable. The obvious solution may be to simply change it to a struct, but that is both not supported and potentially undesirable for other reasons we'll get to later.

Instead, you can use xref:IPA.Config.Stores.Attributes.NonNullableAttribute. Change the definition of SubThings to this:

[!code-csPluginConfig.cs#sub-basic-nonnull]

And add this to the usings:

[!code-csPluginConfig.cs#includes-attributes]

This attribute tells the serializer that null is an invalid value for the config object. This does, however, require that you take extra care ensure that it never becomes null in code, as that will break the serializer.


What about collection types?

Well, you can use those too, but you have to use something new: a converter.

You may be familiar with them if you have used something like the popular Newtonsoft.Json library before. In BSIPA, they lie in the xref:IPA.Config.Stores.Converters namespace. All converters either implement xref:IPA.Config.Stores.IValueConverter or derive from xref:IPA.Config.Stores.ValueConverter`1. You will mostly use them with an xref:IPA.Config.Stores.Attributes.UseConverterAttribute.

To use them, we'll want to import them:

[!code-csPluginConfig.cs#includes-attributes]

Then add a field, for example a list field:

[!code-csPluginConfig.cs#list-basic]

This uses a converter that is provided with BSIPA for xref:System.Collections.Generic.List`1s specifically. It converts the list to an ordered array, which is then written to disk as a JSON array.

We could also potentially want use something like a xref:System.Collections.Generic.HashSet`1. Lets start by looking at the definition for such a member, then deciphering what exactly it means:

[!code-csPluginConfig.cs#set-basic]

The converter we're using here is xref:IPA.Config.Stores.Converters.CollectionConverter`2, a base type for converters of all kinds of collections. In fact, the xref:IPA.Config.Stores.Converters.ListConverter`1 is derived from this, and uses it for most of its implementation. If a type implements xref:System.Collections.Generic.ICollection`1, xref:IPA.Config.Stores.Converters.CollectionConverter`2 can convert it.

It, like most other BSIPA provided aggregate converters, provides a type argument overload xref:IPA.Config.Stores.Converters.CollectionConverter`3 to compose other converters with it to handle unusual element types.

Now after all that, your plugin class has not changed, and your config class should look something like this:

[!code-csPluginConfig.cs#basic-complete]


I mentioned earlier that your config file will be automatically reloaded -- but isn't that a bad thing? Doesn't that mean that the config could change under your feet without you having a way to tell?

Not so- I just haven't introduced the mechanism.

Define a public or protected virtual method named OnReload:

[!code-csPluginConfig.cs#on-reload]

This method will be called whenever BSIPA reloads your config from disk. When it is called, the object will already have been populated. Use it to notify all of your systems that configuration has changed.


Now, we know how to read from disk, and how to use unusual types, but how do we write it back to disk?

This config system is based on automatic saving (though we haven't quite gotten to the automatic part), and so the config is written to disk whenever the system recognizes that something has changed. To tell is as much, define a public or protected virtual method named Changed:

[!code-csPluginConfig.cs#changed]

This method can be called to tell BSIPA that this config object has changed. Later, when we enable automated change tracking, this will also be called when one of the config's members changes. You can use this body to validate something or, for example, write a timestamp for last change.


I just mentioned automated change tracking -- lets add that now.

To do this, just make all of the properties virtual, like so:

[!code-csPluginConfig.cs#auto-props]

Now, whenever you assign to any of those properties, your Changed method will be called, and the config object will be marked as changed and will be written to disk. Unfortunately, any properties that can be modified while only using the property getter do not trigger this, and so if you change any collections for example, you will have to manually call Changed.

After doing all this, your config class should look something like this:

[!code-csPluginConfig.cs#basic-complete]


There is one more major problem with this though: the main class is still public. Most configs shouldn't be. Lets make it internal.

So we make it internal:

[!code-csPluginConfig.cs#internal]

But to make it actually work, we add this outside the namespace declaration:

[!code-csPluginConfig.cs#internals-visible]

And now our full file looks like this:

[!code-csPluginConfig.cs#basic-complete]