SMAPI - Stardew Modding API

SMAPI - Stardew Modding API

971k Downloads

Redesign content interception API

Pathoschild opened this issue · 8 comments

commented

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:
    1. Mods can provide any game content asset (even if it doesn't exist in the Content folder), and/or apply any number of edits.
    2. Mods have access to the same asset edit helpers as before (e.g. asset.AsMap().PatchMap(…)).
    3. Mods cannot intercept private local assets within another mod.
    4. SMAPI can track which mods intercepted an asset for logging and to list errors under the mod's name.
    5. SMAPI can prevent infinite recursion which would crash the game (e.g. mod provides an asset by loading the same key).
  • Improvements:
    1. The content API is discoverable through helper like the other APIs.
    2. Mods can skip loading if it would cause a load conflict (e.g. if they're just loading an empty file to edit it).
    3. Mods can set load/edit priority, so they can make changes before/after another mod.
    4. Mods can be notified after an asset was loaded/edited (to avoid the CanEdit + 1 tick hack).
    5. Mods can add a content pack label for logged messages (e.g. "Content Patcher (for Hobbit House) reordered tilesheets …").
    6. Mods can apply multiple edits in sequence so that any errors/warnings are logged for the correct content pack label.
commented

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.

commented

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).

commented

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 of Load or Edit calls to apply. Each Load/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!";
            });
        }
    }
}
commented

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.

commented

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.

commented

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 and CanLoad/Load in the current design.
  • Mods can use event priorities, though we could also add a separate priority option to support content pack frameworks.
commented

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?

commented

The usual deprecation policy would apply, so the old API would still work as-is until SMAPI 4.0 (probably sometime next year). For changes like this I also submit PRs to update every open-source mod that'd break before I remove the old API.