Redesign content interception API
Pathoschild opened this issue · 8 comments
Content interception lets mods replace/edit any asset loaded from the Content
folder. This was added in SMAPI 2.0 and has been very popular, but there are improvements that aren't possible with the current design. Create a new API which would replace it in SMAPI 4.0.
Scope
This only affects the content interception API (i.e. IAssetLoader
and IAssetEditor
), not the helper.Content
portion of the API.
Design goals
The new API should meet these constraints:
- Keep existing functionality:
- Mods can provide any game content asset (even if it doesn't exist in the
Content
folder), and/or apply any number of edits. - Mods have access to the same asset edit helpers as before (e.g.
asset.AsMap().PatchMap(…)
). - Mods cannot intercept private local assets within another mod.
- SMAPI can track which mods intercepted an asset for logging and to list errors under the mod's name.
- SMAPI can prevent infinite recursion which would crash the game (e.g. mod provides an asset by loading the same key).
- Mods can provide any game content asset (even if it doesn't exist in the
- Improvements:
- The content API is discoverable through
helper
like the other APIs. - Mods can skip loading if it would cause a load conflict (e.g. if they're just loading an empty file to edit it).
- Mods can set load/edit priority, so they can make changes before/after another mod.
- Mods can be notified after an asset was loaded/edited (to avoid the
CanEdit
+ 1 tick hack). - Mods can add a content pack label for logged messages (e.g. "Content Patcher (for Hobbit House) reordered tilesheets …").
- Mods can apply multiple edits in sequence so that any errors/warnings are logged for the correct content pack label.
- The content API is discoverable through
These changes look great. It'll be nice to see the asset loaders/editors handled through events rather than needing to create a new class for the loader/editor. Also, being able to see when assets are loaded without needing to create an asset editor is a huge addition in my opinion. My use case here is needing to know when an asset is loaded to update a custom CP token. I don't need to edit the asset, just see when it's updated and get the latest value.
Copying a conversation from Discord where the AssetInvalidatedWithoutPropagation
event would be perfect for a use case I had. Conversation starts here: https://discord.com/channels/137344473976799233/156109690059751424/901323215073345606
Edit: To fit my use case, this event would need to fire even for assets not in the cache (i.e. mod assets, which are not cached).
The next game update will add a DoesAssetExist
check, so SMAPI needs to know if a mod will load an asset before the AssetLoading
step. So we'll need to re-redesign the API to allow for that.
Overview
This new redesign replaces IAssetEditor
and IAssetLoader
with two features:
-
An API to register asset changes. You can target the asset(s) to change using
helper.Content.ChangeAssets(...)
, then add any number ofLoad
orEdit
calls to apply. EachLoad
/Edit
call can set options like priority, conflict resolution, content pack labels, etc.The proposed API is still a bit different from the other APIs (due to the unique constraints like needing to register changes before they're applied), but is still much more discoverable than the interfaces.
-
New events under
helper.Events.Content
to react after assets are loaded/edited, invalidated, or ready.
Fluent examples
The simplest cases would apply one edit or load:
helper.Content
.ChangeAssets("Portraits/Abigail")
.Load(context => context.FromModFolder<Texture2D>("assets/portraits.png"));
You can apply any number of load/edit changes to one asset:
helper.Content
.ChangeAssets("Portraits/Abigail")
.Load(context => context.FromModFolder<Texture2D>("assets/portraits.png"))
.Edit(context => context.EditAsTexture(editor => editor.PatchImage(...))
.Edit(context => ...);
You can also target multiple assets at once:
helper.Content
.ChangeAssets("Characters/Dialogue/Bobby", "Characters/Dialogue/Johnny", "Characters/Dialogue/Robby")
.Load(context =>
{
string name = context.AssetName.GetSegments().Last();
return context.FromModFolder<Texture2D>($"assets/{name}.json");
});
You can also make dynamic changes, add the content pack ID it should be logged under, set edit priority, and more. The ChangeAssets(asset => ...)
form is called each time the game is loading the asset, so you don't need to know ahead of time whether you'll edit an asset (e.g. if it depends on the game state). For example:
helper.Content
.ChangeAssets(asset => context.AssetName.StartsWith("Characters/Dialogue/"))
.Edit(context =>
{
context
.ForContentPackId("content-pack-id")
.WithPriority(EditPriority.Low)
.EditAsDictionary<string, string>(editor =>
{
editor.Data["Mon"] = "What a beautiful Monday morning!";
});
);
Method example
You can also move the logic into separate methods if you want. For example, you can do something similar to the old IAssetEditor
/IAssetLoader
methods:
public class ModEntry : Mod
{
public override void Entry(IModHelper helper)
{
helper.Content
.ChangeAssets(this.CanEdit)
.Edit(this.Edit);
}
private bool CanEdit(IAssetInfo asset)
{
if (asset.Name.StartsWith("Characters/Dialogue/"))
return true;
return false;
}
private void Edit(IAssetEditContext context)
{
if (asset.Name.StartsWith("Characters/Dialogue/"))
{
context.EditAsDictionary<string, string>(editor =>
{
IDictionary<string, string> data = editor.Data;
data["Mon"] = "What a beautiful Monday morning!";
});
}
}
}
I've been trying a few different API implementations. So far I think the event approach (with some tweaks to work with the latest game alpha) is still the most intuitive one. That would mainly add an AssetRequested
event to replace IAssetLoader
/IAssetEditor
:
/// <summary>Raised when an asset is being requested from the content pipeline.</summary>
/// <remarks>
/// The asset isn't necessarily being loaded yet (e.g. the game may be checking if it exists). Mods can
/// register the changes they want to apply using methods on the <paramref name="e"/> parameter. These will
/// be applied when the asset is actually loaded.
///
/// If the asset is requested multiple times in the same tick (e.g. once to check if it exists and once to
/// load it), SMAPI might only raise the event once and reuse the cached result.
/// </remarks>
event EventHandler<AssetRequestedEventArgs> AssetRequested;
The most common uses would look something like this:
private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
{
if (e.Name.IsEquivalentTo("Portraits/Abigail"))
{
// load custom portraits
e.LoadFromModFile<Texture2D>("assets/portrait.png");
// apply an overlay edit
e.Edit(
editor => editor.AsImage().PatchImage(source: this.RibbonTexture)
);
}
}
Those methods would also have optional arguments for more advanced cases:
private void OnAssetRequested(object sender, AssetRequestedEventArgs e)
{
if (e.Name.IsEquivalentTo("Characters/Dialogue/SomeCustomNpc"))
{
// load an empty data file, except if it's already being loaded by another mod
e.LoadFromModFile<Dictionary<string, string>>("assets/empty.json", OnLoadConflict.SkipThisLoad);
// apply a data edit *after* most other edits to this asset
e.Edit(
editor => editor.AsDictionary<string, string>()["Mon"] = "Oh no, is it Monday already?$s",
EditPriority.Later
);
}
}
This would include the AssetReady
and AssetInvalidatedWithoutPropagation
events from the original planned redesign.
Done in develop
for the upcoming SMAPI 3.14.0. See content events and Migrate to SMAPI 4.0 on the wiki for more info.
One option is to replace IAssetLoader
/IAssetEditor
with three new events under helper.Events.Content
:
event | description |
---|---|
AssetLoading |
The game is loading an asset. Mods can provide the data to use at this point (equivalent to the current IAssetLoader ). If no mod provides the data, SMAPI will try loading it from the game's content folder. |
AssetEditing |
An asset was just loaded, but hasn't been cached or referenced yet. Mods can apply edits at this point (equivalent to the current IAssetEditor ). |
AssetReady |
An asset was just loaded and cached for a content manager, and will be returned to the game immediately after this event. Assets shouldn't be edited at this point. |
AssetInvalidatedWithoutPropagation |
An asset has been invalidated from the cache (so it'll be reloaded next time it's requested), but SMAPI didn't reload it automatically as part of its asset propagation. This is mainly useful for mod files exposed through the content API, to detect when the file changes even though it's not reloaded automatically. |
For example:
private void OnAssetLoading(object sender, AssetLoadingEventArgs e)
{
// Load an empty schedule file for our custom NPC.
// If another mod wants to load this file too, let them do it instead.
if (e.Asset.AssetNameEquals("Characters/Schedules/Jane"))
e.QueueLoad(() => new Dictionary<string, string>(), ConflictResolution.Skip);
}
private void OnAssetEditing(object sender, AssetEditingEventArgs e)
{
if (e.Asset.AssetNameEquals("Maps/JaneHouse"))
e.QueueEdit(asset => asset.AsMap().PatchMap(...));
}
private void OnAssetReady(object sender, AssetReadyEventArgs)
{
if (e.Asset.AssetNameEquals("Characters/Jane"))
{
Texture2D spritesheet = e.GetData<Texture2D>();
…
}
}
Some benefits of this approach:
- The event args can provide relevant methods and options, like conflict resolution in an
AssetLoading
handler. New methods/options can also be added in the future without breaking existing mods, unlike the interface approach. - This minimizes duplicate logic between
CanEdit
/Edit
andCanLoad
/Load
in the current design. - Mods can use event priorities, though we could also add a separate priority option to support content pack frameworks.
How would this affect existing mods? Would the need re-written, or would legacy support continue to exist in cases where the mod author has gone AFK?