Skip to content

Commit

Permalink
rewrite content loading to allow handling locale variants (#766, #786,
Browse files Browse the repository at this point in the history
…#812)

The game's content pipeline automatically loads localized variants if present. For example, it will try to load "Maps/cave.fr-FR", then "Maps/cave_international", then "Maps/cave". The old content API obfuscates this logic and treats them as interchangeable, which causes edge cases like bundle corruption (#812). This commit rewrites the loading logic to match the game logic when using the new content events, while maintaining the legacy behavior for the old IAssetLoader/IAssetEditor interfaces that'll be removed in SMAPI 4.0.0.
  • Loading branch information
Pathoschild committed Mar 26, 2022
1 parent ad89120 commit 4c64f9f
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 160 deletions.
1 change: 1 addition & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* Added `--use-current-shell` to avoid opening a separate terminal window.
* Fixed `--no-terminal` still opening a terminal window, even if nothing is logged to it (thanks to Ryhon0!).
* Fixed warning text when a mod causes an asset load conflict with itself.
* Fixed support for `_international` content assets (used in the movie theater).

* For mod authors:
* Added [content events](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Events#Content), which will replace `IAssetEditor` and `IAssetLoader` in SMAPI 4.0.0.
Expand Down
89 changes: 77 additions & 12 deletions src/SMAPI/Framework/ContentCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,6 @@ internal class ContentCoordinator : IDisposable
/// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary>
private readonly List<IContentManager> ContentManagers = new();

/// <summary>The language code for language-agnostic mod assets.</summary>
private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage;

/// <summary>Whether the content coordinator has been disposed.</summary>
private bool IsDisposed;

Expand Down Expand Up @@ -350,7 +347,7 @@ public T LoadManagedAsset<T>(string contentManagerID, IAssetName relativePath)
throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod.");

// get fresh asset
return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false);
return contentManager.LoadExact<T>(relativePath, useCache: false);
}

/// <summary>Purge matched assets from the cache.</summary>
Expand Down Expand Up @@ -467,9 +464,9 @@ public IEnumerable<object> GetLoadedValues(IAssetName assetName)
return this.ContentManagerLock.InReadLock(() =>
{
List<object> values = new List<object>();
foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName, p.Language)))
foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName)))
{
object value = content.Load<object>(assetName, this.Language, useCache: true);
object value = content.LoadExact<object>(assetName, useCache: true);
values.Add(value);
}
return values;
Expand Down Expand Up @@ -582,6 +579,8 @@ private bool TryLoadVanillaAsset<T>(string assetName, out T asset)
/// <param name="info">The asset info to load or edit.</param>
private IEnumerable<AssetOperationGroup> GetAssetOperationsWithoutCache<T>(IAssetInfo info)
{
IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info);

// new content API
foreach (AssetOperationGroup group in this.RequestAssetOperations(info))
yield return group;
Expand All @@ -592,12 +591,12 @@ private IEnumerable<AssetOperationGroup> GetAssetOperationsWithoutCache<T>(IAsse
// check if loader applies
try
{
if (!loader.Data.CanLoad<T>(info))
if (!loader.Data.CanLoad<T>(legacyInfo))
continue;
}
catch (Exception ex)
{
loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
continue;
}

Expand All @@ -610,7 +609,9 @@ private IEnumerable<AssetOperationGroup> GetAssetOperationsWithoutCache<T>(IAsse
mod: loader.Mod,
priority: AssetLoadPriority.Exclusive,
onBehalfOf: null,
getData: assetInfo => loader.Data.Load<T>(assetInfo)
getData: assetInfo => loader.Data.Load<T>(
this.GetLegacyAssetInfo(assetInfo)
)
)
},
editOperations: Array.Empty<AssetEditOperation>()
Expand All @@ -623,12 +624,12 @@ private IEnumerable<AssetOperationGroup> GetAssetOperationsWithoutCache<T>(IAsse
// check if editor applies
try
{
if (!editor.Data.CanEdit<T>(info))
if (!editor.Data.CanEdit<T>(legacyInfo))
continue;
}
catch (Exception ex)
{
editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
continue;
}

Expand All @@ -642,11 +643,75 @@ private IEnumerable<AssetOperationGroup> GetAssetOperationsWithoutCache<T>(IAsse
mod: editor.Mod,
priority: AssetEditPriority.Default,
onBehalfOf: null,
applyEdit: assetData => editor.Data.Edit<T>(assetData)
applyEdit: assetData => editor.Data.Edit<T>(
this.GetLegacyAssetData(assetData)
)
)
}
);
}
}

/// <summary>Get an asset info compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary>
/// <param name="asset">The asset info.</param>
private IAssetInfo GetLegacyAssetInfo(IAssetInfo asset)
{
if (!this.TryGetLegacyAssetName(asset.Name, out IAssetName legacyName))
return asset;

return new AssetInfo(
locale: null,
assetName: legacyName,
type: asset.DataType,
getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName
);
}

/// <summary>Get an asset data compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary>
/// <param name="asset">The asset data.</param>
private IAssetData GetLegacyAssetData(IAssetData asset)
{
if (!this.TryGetLegacyAssetName(asset.Name, out IAssetName legacyName))
return asset;

return asset.Name.LocaleCode == null
? asset
: new AssetDataForObject(
locale: null,
assetName: legacyName,
data: asset.Data,
getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName
);
}

/// <summary>Get an asset name compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary>
/// <param name="asset">The asset name to map.</param>
/// <param name="newAsset">The legacy asset name (or the <paramref name="asset"/> if no change is needed).</param>
/// <returns>Returns whether any change is needed for legacy compatibility.</returns>
private bool TryGetLegacyAssetName(IAssetName asset, out IAssetName newAsset)
{
// strip _international suffix
const string internationalSuffix = "_international";
if (asset.Name.EndsWith(internationalSuffix))
{
newAsset = new AssetName(
baseName: asset.Name[..^internationalSuffix.Length],
localeCode: null,
languageCode: null
);
return true;
}

// else strip locale
if (asset.LocaleCode != null)
{
newAsset = new AssetName(asset.BaseName, null, null);
return true;
}

// else no change needed
newAsset = asset;
return false;
}
}
}
71 changes: 61 additions & 10 deletions src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Globalization;
using System.IO;
using System.Linq;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Exceptions;
Expand Down Expand Up @@ -32,14 +33,17 @@ internal abstract class BaseContentManager : LocalizedContentManager, IContentMa
/// <summary>Whether to enable more aggressive memory optimizations.</summary>
protected readonly bool AggressiveMemoryOptimizations;

/// <summary>Whether to automatically try resolving keys to a localized form if available.</summary>
protected bool TryLocalizeKeys = true;

/// <summary>Whether the content coordinator has been disposed.</summary>
private bool IsDisposed;

/// <summary>A callback to invoke when the content manager is being disposed.</summary>
private readonly Action<BaseContentManager> OnDisposing;

/// <summary>A list of disposable assets.</summary>
private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>();
private readonly List<WeakReference<IDisposable>> Disposables = new();

/// <summary>The disposable assets tracked by the base content manager.</summary>
/// <remarks>This should be kept empty to avoid keeping disposable assets referenced forever, which prevents garbage collection when they're unused. Disposable assets are tracked by <see cref="Disposables"/> instead, which avoids a hard reference.</remarks>
Expand Down Expand Up @@ -115,11 +119,51 @@ public override T Load<T>(string assetName)
public override T Load<T>(string assetName, LanguageCode language)
{
IAssetName parsedName = this.Coordinator.ParseAssetName(assetName);
return this.Load<T>(parsedName, language, useCache: true);
return this.LoadLocalized<T>(parsedName, language, useCache: true);
}

/// <inheritdoc />
public abstract T Load<T>(IAssetName assetName, LanguageCode language, bool useCache);
public T LoadLocalized<T>(IAssetName assetName, LanguageCode language, bool useCache)
{
// ignore locale in English (or if disabled)
if (!this.TryLocalizeKeys || language == LocalizedContentManager.LanguageCode.en)
return this.LoadExact<T>(assetName, useCache: useCache);

// check for localized asset
if (!LocalizedContentManager.localizedAssetNames.TryGetValue(assetName.Name, out _))
{
string localeCode = this.LanguageCodeString(language);
IAssetName localizedName = new AssetName(baseName: assetName.BaseName, localeCode: localeCode, languageCode: language);

try
{
this.LoadExact<T>(localizedName, useCache: useCache);
LocalizedContentManager.localizedAssetNames[assetName.Name] = localizedName.Name;
}
catch (ContentLoadException)
{
localizedName = new AssetName(assetName.BaseName + "_international", null, null);
try
{
this.LoadExact<T>(localizedName, useCache: useCache);
LocalizedContentManager.localizedAssetNames[assetName.Name] = localizedName.Name;
}
catch (ContentLoadException)
{
LocalizedContentManager.localizedAssetNames[assetName.Name] = assetName.Name;
}
}
}

// use cached key
string rawName = LocalizedContentManager.localizedAssetNames[assetName.Name];
if (assetName.Name != rawName)
assetName = this.Coordinator.ParseAssetName(assetName.Name);
return this.LoadExact<T>(assetName, useCache: useCache);
}

/// <inheritdoc />
public abstract T LoadExact<T>(IAssetName assetName, bool useCache);

/// <inheritdoc />
public virtual void OnLocaleChanged() { }
Expand Down Expand Up @@ -154,7 +198,11 @@ public string GetLocale(LanguageCode language)
}

/// <inheritdoc />
public abstract bool IsLoaded(IAssetName assetName, LanguageCode language);
public bool IsLoaded(IAssetName assetName)
{
return this.Cache.ContainsKey(assetName.Name);
}


/****
** Cache invalidation
Expand Down Expand Up @@ -241,26 +289,29 @@ protected string NormalizePathSeparators(string path)
/// <typeparam name="T">The type of asset to load.</typeparam>
/// <param name="assetName">The normalized asset key.</param>
/// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param>
protected virtual T RawLoad<T>(string assetName, bool useCache)
protected virtual T RawLoad<T>(IAssetName assetName, bool useCache)
{
return useCache
? base.LoadBase<T>(assetName)
: base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable)));
? base.LoadBase<T>(assetName.Name)
: base.ReadAsset<T>(assetName.Name, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable)));
}

/// <summary>Add tracking data to an asset and add it to the cache.</summary>
/// <typeparam name="T">The type of asset to inject.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="value">The asset value.</param>
/// <param name="language">The language code for which to inject the asset.</param>
/// <param name="useCache">Whether to save the asset to the asset cache.</param>
protected virtual void TrackAsset<T>(IAssetName assetName, T value, LanguageCode language, bool useCache)
protected virtual void TrackAsset<T>(IAssetName assetName, T value, bool useCache)
{
// track asset key
if (value is Texture2D texture)
texture.Name = assetName.Name;

// cache asset
// save to cache
// Note: even if the asset was loaded and cached right before this method was called,
// we need to fully re-inject it because a mod editor may have changed the asset in a
// way that doesn't change the instance stored in the cache, e.g. using
// `asset.ReplaceWith`.
if (useCache)
this.Cache[assetName.Name] = value;

Expand Down
Loading

0 comments on commit 4c64f9f

Please sign in to comment.