Summary
Allow mods to add fields to instances in the game in a way that's more compatible and more performant than existing solutions.
Background
Often times a mod will need to add new data to existing entities in the game. There are multiple ways to approach this, with various caveats:
IHaveModData.modData - Most things implement this interface, so it's pretty widely useful. Caveats:
- Can only store strings, which makes it unsuitable for complex data or non-string data used frequently (such as during rendering).
- Always synced, making it unsuitable for client-side-only data.
- No control over how the value is synced (ex.
NetInt vs NetIntDelta)
- Always serialized, which may not be desired.
ConditionalWeakTable - Usable with basically anything, and allows full freedom with the format/structure of data.
- For instances that can be cloned (ex.
Item.getOne()), needs harmony patching to copy the data to the new instance.
- Very slow, making it unsuitable when used frequently.
- Syncing requires harmony patching
initNetFields() (or equivalent)
- Serializing requires harmony patching the xml serializer (ex. the SpaceCore API's implementation)
- Custom C# subclass - Only really suitable for new types of entities, but most of the previous caveats are non-existent. Caveats:
- As mentioned, only really suitable for entire new types.
- Requires editing the save serializer to save properly, which is tricky for more than one mod to do at once, and also makes a warning appear in the console when SMAPI loads the mod (which scares users even if you tell them it will happen).
- Saves with these types in them fail to load without the mod, unless you have special handling to remove them from the resulting XML when saving and restore them before loading. Currently, people rely on SpaceCore for this.
- Handling your data entirely separately - This can work in some limited cases, but has many problems:
- It won't actually be attached to the relevant objects, making it mostly infeasible for things that may occur in many different configurations (such as on items).
- It's easy to forget resetting this data when returning to the title screen.
- You must handle syncing yourself, which can be very error prone, and the simplest way to do so can be very inefficient or difficult.
- The simplest way being the SMAPI
IMultiplayerHelper API for sending/receiving messages.
- Inefficient: The data is serialized as json, which makes the messages much larger than normal synced fields that are binary serialized.
- Difficult: Base game types such as
Item do not properly serialize to json out of the box, meaning you need custom handling which could include or exclude fields which aren't normally included/excluded.
- You must handle serialization yourself, which has the same problems as the first bullet point, but also can only be done on the host (meaning you also get all the caveats of handling syncing yourself).
While reworking some vanilla netfield registration code to make it cleaner and more robust, we found a way to allow mods to add new netfields without the performance caveats or other unique-to-netfield issues (like obscure connection problems which are actually due to netfield mismatches). The same mechanism can be used to add serialized and unsynced fields, too.
Goals
A SMAPI API that allows the following:
- Attaching new transient fields to
INetObject-implementing instances.
- Marking a new field to be serialized.
- Marking a new field to be synced.
- Marking a new field to be synced, but isn't mandatory for others to have it to be able to connect.
A separate proposal will cover the case of entirely new types for serialization and/or net sync.
API
A new helper (tentatively IFieldHelper) would contain the functions for this API.
- A registration function, which must be called early in the game's lifecycle:
- A type parameter for what type (
TParent) this field should be registered to
- A parameter for the field name (unique to the mod that the helper belongs to)
- A parameter for the delegate that creates the field instance
- A parameter for controlling the behavior of the new field (
CustomDataFieldBehavior, see below)
- Returns a delegate which can be used to get a
ref to the custom field when given an instance of TParent.
- A function for retrieving a field already registered:
- A type parameter for what type (
TParent) this field should be registered to
- A parameter for the
IManifest of the mod that registered the field you want
- A parameter for the field name (unique to the mod that the helper belongs to)
- Returns the same delegate originally returned by this field's registration call
Since the timing for registration is strict, we could also have an event for when registration should happen. This would allow letting mods finish doing their initial setup, while also making it easy to register fields at the right time without going past when it should happen.
Early draft of the proposed API:
/// <summary>Flags for determining additional behavior for mod-added data fields.</summary>
[Flags]
public enum CustomDataFieldBehavior
{
/// <summary>No special behavior for the field will be applied. Useful for transient data, like something only used for local rendering.</summary>
None = 0,
/// <summary>The field will be serialized upon game save and deserialized upon game load.</summary>
Serialized = 1 << 0,
/// <summary>The field will be synced as a required field. Everyone must have the exact same set of required synced fields to connect with each other. The field's type must implement either <see cref="INetSerializable"/> or <see cref="INetObject{NetFields}"/>.</summary>
Synced = 1 << 1,
/// <summary>The field will be synced as an optional field. Optional synced fields are not mandatory for others to have. The field's type must implement either <see cref="INetSerializable"/> or <see cref="INetObject{NetFields}"/>.</summary>
SyncedOptional = 1 << 2,
}
/// <summary>Delegate for creating a field that will be added to a type.</summary>
/// <returns>The created field with default values having been set (if any).</returns>
/// <typeparam name="TValueType">The type of the field being returned.</typeparam>
delegate TValueType CreateFieldDelegate<TValueType>();
/// <summary>Delegate for getting the field for a given instance of TParent.</summary>
/// <typeparam name="TParent">The type of the instance to get the field from.</typeparam>
/// <typeparam name="TValueType">The type of the field to get.</typeparam>
/// <param name="parent">The instance to get the field from.</param>
/// <returns>The field for the given instance.</returns>
delegate ref TValueType GetFieldDelegate<TParent, TValueType>(TParent parent)
where TParent : INetObject<NetFields>;
// The note about the `GameLaunched` event could be changed depending on when the cutoff needs to be.
/// <summary>Create a new field on <see cref="TParent" /> and all derived classes.</summary>
/// <remarks>This must be called before the <see cref="StardewModdingAPI.Events.IGameLoopEvents.GameLaunched"/> event is raised.</remarks>
/// <typeparam name="TParent">The type to register the field on.</typeparam>
/// <typeparam name="TValueType">The type of the field to register.</typeparam>
/// <param name="name">The name of field to create on <see cref="TParent" />. This name is unique to your mod, but must not be shared by any other fields on that type (including among parent types of <see cref="TParent" />).</param>
/// <param name="createDelegate">A delegate to create the field instance, with any default values already set.</param>
/// <param name="behavior">The additional behavior of the created field, if any.</param>
/// <returns>A delegate for getting the corresponding field for a given instance of <see cref="TParent" />.</returns>
GetFieldDelegate<TParent, TValueType> CreateObjectField<TParent, TValueType>(string name, CreateFieldDelegate<TValueType> createDelegate, CustomDataFieldBehavior behavior)
where TParent : INetObject<NetFields>;
/// <summary>Create a new field on <see cref="TParent" /> and all derived classes.</summary>
/// <typeparam name="TParent">The type to register the field on.</typeparam>
/// <typeparam name="TValueType">The type of the field to register.</typeparam>
/// <param name="name">The name of field to create on <see cref="TParent" />. This name is unique to your mod, but must not be shared by any other fields on that type (including among parent types of <see cref="TParent" />).</param>
/// <param name="createDelegate">A delegate to create the field instance, with any default values already set.</param>
/// <param name="behavior">The additional behavior of the created field, if any.</param>
/// <returns>A delegate for getting the corresponding field for a given instance of <see cref="TParent" />.</returns>
GetFieldDelegate<TParent, TValueType>? GetObjectFieldOnType<TParent, TValueType>(IManifest owningMod, string name)
where TParent : INetObject<NetFields>;
Implementation
ConditionalWeakTable is too slow to be useful for many cases, but an upcoming change in vanilla will have a field that SMAPI can manage to allow having a single delegate for things.
To have the fields be delegates and fields rather than a ConditionalWeakTable or Dictionary lookup, we can generate the objects and access delegates using System.Reflection.Emit (similar to what the base game does for LocalMultiplayer static field instancing). This would be a rather complex implementation detail for SMAPI itself, so I will be making a separate and generic library for the task. @Pathoschild has given his approval for this.
Integration with the base game
The base game has a new system focused on what is called a protocol summary. The ProtocolSummary class has an index of every type that can be netsynced, and each type has a list of fields in that type. New netfields cannot be added to a NetFields instance without being part of this protocol summary.
The most important part of this system is a new field on NetFields which allows the ModHooks implementation to store a IHaveAdditionalNetFields implementation specific to that NetFields's owning instance. This interface includes hooks for required and optional netfields, but we can use this same instance for holding other fields as well.
SMAPI would then inject required synced fields into the protocol summary system. Netfields can no longer be able to be added unless they are in this new system anyways, and it's important that SMAPI manages it since the ordering needs to be consistent between clients.
Optional synced fields would be similar to required synced fields, but need more handling on the SMAPI side. The hooks are for reading/writing bytes directly, for both full and delta versions.
Serialized fields could be implemented in multiple ways:
- A small vanilla change to have unknown fields be placed in new
[XmlAnyElements] field at the top of every relevant object hierarchy.
- This handles deserialization, but what about serialization? Needs testing, but probably need something separate for that. Potentially even a harmony patch.
- Patching
System.Xml.Serialization in the same way that SpaceCore currently does for its RegisterCustomProperty function. This has been previously tested to make sure they do not impair loading a save with new modded fields in vanilla, but would be worth testing more extensively if this route is desired.
Integration with the IL generation library
TODO
Limitations
This has very few caveats on the mod author side, but there would need to be a hard cutoff as to when new fields could no longer be registered. This is because the IL generation needs to happen early on to be able to use the resulting objects, and because ProtocolSummary needs to be locked in early on.
I originally wrote the following while thinking about the part for entirely new serialized types. This isn't as relevant for this proposal, unless the serialization implementation route requires patching `System.Xml.Serialization`.
On the SMAPI side, it would require using harmony for the serialization implementation. SMAPI currently only uses harmony patching for MiniMonoModHotfix to patch an unusual issue with Harmony/MonoMod and XNA, meaning SMAPI itself could have no patching at some point if/when we update those dependencies with a fix. The proposed serialization implementation would prevent SMAPI from having no harmony patches itself, since it would be unreasonable to expect the dotnet team to add functionality for our use case (and if they hypothetically did, it would also require making SMAPI use a .NET version newer than the game itself does).
Additional questions
- If we go with the
[XmlAnyElements] approach for serialized field handling, is [XmlAnyElement] really enough for the serialization process? It works with deserialization, but I'm more uncertain about serialization. We may need an IModHooks callback or a harmony patch for that part.
- With some small additional changes to vanilla, this could be expanded to allow new complex fields in the various
StardewValley.GameData data models.
- This would be useful because most
CustomFields fields only support string values, which is very limiting for complex data. This is why SpaceCore has several spacechase0.SpaceCore/*ExtensionData assets, for example.
- Content Patcher and similar would need a custom JsonConverter to put values into the appropriate field.
- The additional changes for vanilla would be a new interface with a single field for SMAPI to manage and the appropriate models implementing the interface.
- Perhaps not as necessary if the
CustomFields that currently have a string value get changed to object values, but would be much more widely usable with under a unified API.
- Many of SMAPI's API do not allow you to directly interact with other mods unless they opt in to it. For example, you can't read the config or data of another mod unless they expose it in their own API, and the older inter-mod messaging does nothing if the receiving mod does not specifically handle it. As such, do we even want to allow mods to directly retrieve fields registered by other mods?
Summary
Allow mods to add fields to instances in the game in a way that's more compatible and more performant than existing solutions.
Background
Often times a mod will need to add new data to existing entities in the game. There are multiple ways to approach this, with various caveats:
IHaveModData.modData- Most things implement this interface, so it's pretty widely useful. Caveats:NetIntvsNetIntDelta)ConditionalWeakTable- Usable with basically anything, and allows full freedom with the format/structure of data.Item.getOne()), needs harmony patching to copy the data to the new instance.initNetFields()(or equivalent)IMultiplayerHelperAPI for sending/receiving messages.Itemdo not properly serialize to json out of the box, meaning you need custom handling which could include or exclude fields which aren't normally included/excluded.While reworking some vanilla netfield registration code to make it cleaner and more robust, we found a way to allow mods to add new netfields without the performance caveats or other unique-to-netfield issues (like obscure connection problems which are actually due to netfield mismatches). The same mechanism can be used to add serialized and unsynced fields, too.
Goals
A SMAPI API that allows the following:
INetObject-implementing instances.A separate proposal will cover the case of entirely new types for serialization and/or net sync.
API
A new helper (tentatively
IFieldHelper) would contain the functions for this API.TParent) this field should be registered toCustomDataFieldBehavior, see below)refto the custom field when given an instance ofTParent.TParent) this field should be registered toIManifestof the mod that registered the field you wantSince the timing for registration is strict, we could also have an event for when registration should happen. This would allow letting mods finish doing their initial setup, while also making it easy to register fields at the right time without going past when it should happen.
Early draft of the proposed API:
Implementation
ConditionalWeakTableis too slow to be useful for many cases, but an upcoming change in vanilla will have a field that SMAPI can manage to allow having a single delegate for things.To have the fields be delegates and fields rather than a
ConditionalWeakTableorDictionarylookup, we can generate the objects and access delegates usingSystem.Reflection.Emit(similar to what the base game does forLocalMultiplayerstatic field instancing). This would be a rather complex implementation detail for SMAPI itself, so I will be making a separate and generic library for the task. @Pathoschild has given his approval for this.Integration with the base game
The base game has a new system focused on what is called a protocol summary. The
ProtocolSummaryclass has an index of every type that can be netsynced, and each type has a list of fields in that type. New netfields cannot be added to aNetFieldsinstance without being part of this protocol summary.The most important part of this system is a new field on
NetFieldswhich allows theModHooksimplementation to store aIHaveAdditionalNetFieldsimplementation specific to thatNetFields's owning instance. This interface includes hooks for required and optional netfields, but we can use this same instance for holding other fields as well.SMAPI would then inject required synced fields into the protocol summary system. Netfields can no longer be able to be added unless they are in this new system anyways, and it's important that SMAPI manages it since the ordering needs to be consistent between clients.
Optional synced fields would be similar to required synced fields, but need more handling on the SMAPI side. The hooks are for reading/writing bytes directly, for both full and delta versions.
Serialized fields could be implemented in multiple ways:
[XmlAnyElements]field at the top of every relevant object hierarchy.System.Xml.Serializationin the same way that SpaceCore currently does for itsRegisterCustomPropertyfunction. This has been previously tested to make sure they do not impair loading a save with new modded fields in vanilla, but would be worth testing more extensively if this route is desired.Integration with the IL generation library
TODO
Limitations
This has very few caveats on the mod author side, but there would need to be a hard cutoff as to when new fields could no longer be registered. This is because the IL generation needs to happen early on to be able to use the resulting objects, and because
ProtocolSummaryneeds to be locked in early on.I originally wrote the following while thinking about the part for entirely new serialized types. This isn't as relevant for this proposal, unless the serialization implementation route requires patching `System.Xml.Serialization`.
Additional questions
[XmlAnyElements]approach for serialized field handling, is[XmlAnyElement]really enough for the serialization process? It works with deserialization, but I'm more uncertain about serialization. We may need anIModHookscallback or a harmony patch for that part.StardewValley.GameDatadata models.CustomFieldsfields only supportstringvalues, which is very limiting for complex data. This is why SpaceCore has severalspacechase0.SpaceCore/*ExtensionDataassets, for example.CustomFieldsthat currently have astringvalue get changed toobjectvalues, but would be much more widely usable with under a unified API.