using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
using StardewValley;
namespace StardewModdingAPI.Framework.ContentManagers
{
/// A content manager which handles reading files from the game content folder with support for interception.
internal class GameContentManager : BaseContentManager
{
/*********
** Fields
*********/
/// The assets currently being intercepted by instances. This is used to prevent infinite loops when a loader loads a new asset.
private readonly ContextHash AssetsBeingLoaded = new ContextHash();
/// Interceptors which provide the initial versions of matching assets.
private IDictionary> Loaders => this.Coordinator.Loaders;
/// Interceptors which edit matching assets after they're loaded.
private IDictionary> Editors => this.Coordinator.Editors;
/// A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded.
private readonly IDictionary IsLocalisableLookup;
/// Whether the next load is the first for any game content manager.
private static bool IsFirstLoad = true;
/// A callback to invoke the first time *any* game content manager loads an asset.
private readonly Action OnLoadingFirstAsset;
/*********
** Public methods
*********/
/// Construct an instance.
/// A name for the mod manager. Not guaranteed to be unique.
/// The service provider to use to locate services.
/// The root directory to search for content.
/// The current culture for which to localise content.
/// The central coordinator which manages content managers.
/// Encapsulates monitoring and logging.
/// Simplifies access to private code.
/// A callback to invoke when the content manager is being disposed.
/// A callback to invoke the first time *any* game content manager loads an asset.
public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action onDisposing, Action onLoadingFirstAsset)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: false)
{
this.IsLocalisableLookup = reflection.GetField>(this, "_localizedAsset").GetValue();
this.OnLoadingFirstAsset = onLoadingFirstAsset;
}
/// Load an asset that has been processed by the content pipeline.
/// The type of asset to load.
/// The asset path relative to the loader root directory, not including the .xnb extension.
/// The language code for which to load content.
public override T Load(string assetName, LanguageCode language)
{
// raise first-load callback
if (GameContentManager.IsFirstLoad)
{
GameContentManager.IsFirstLoad = false;
this.OnLoadingFirstAsset();
}
// normalise asset name
assetName = this.AssertAndNormaliseAssetName(assetName);
if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
return this.Load(newAssetName, newLanguage);
// get from cache
if (this.IsLoaded(assetName))
return base.Load(assetName, language);
// get managed asset
if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
{
T managedAsset = this.Coordinator.LoadAndCloneManagedAsset(assetName, contentManagerID, relativePath, language);
this.Inject(assetName, managedAsset, language);
return managedAsset;
}
// load asset
T data;
if (this.AssetsBeingLoaded.Contains(assetName))
{
this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn);
this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace);
data = base.Load(assetName, language);
}
else
{
data = this.AssetsBeingLoaded.Track(assetName, () =>
{
string locale = this.GetLocale(language);
IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormaliseAssetName);
IAssetData asset =
this.ApplyLoader(info)
?? new AssetDataForObject(info, base.Load(assetName, language), this.AssertAndNormaliseAssetName);
asset = this.ApplyEditors(info, asset);
return (T)asset.Data;
});
}
// update cache & return data
this.Inject(assetName, data, language);
return data;
}
/// Inject an asset into the cache.
/// The type of asset to inject.
/// The asset path relative to the loader root directory, not including the .xnb extension.
/// The asset value.
/// The language code for which to inject the asset.
public override void Inject(string assetName, T value, LanguageCode language)
{
// handle explicit language in asset name
{
if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
{
this.Inject(newAssetName, value, newLanguage);
return;
}
}
base.Inject(assetName, value, language);
// track whether the injected asset is translatable for is-loaded lookups
string keyWithLocale = $"{assetName}.{this.GetLocale(language)}";
if (this.Cache.ContainsKey(keyWithLocale))
{
this.IsLocalisableLookup[assetName] = true;
this.IsLocalisableLookup[keyWithLocale] = true;
}
else if (this.Cache.ContainsKey(assetName))
{
this.IsLocalisableLookup[assetName] = false;
this.IsLocalisableLookup[keyWithLocale] = false;
}
else
this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error);
}
/// Perform any cleanup needed when the locale changes.
public override void OnLocaleChanged()
{
base.OnLocaleChanged();
// find assets for which a translatable version was loaded
HashSet removeAssetNames = new HashSet(StringComparer.InvariantCultureIgnoreCase);
foreach (string key in this.IsLocalisableLookup.Where(p => p.Value).Select(p => p.Key))
removeAssetNames.Add(this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) ? assetName : key);
// invalidate translatable assets
string[] invalidated = this
.InvalidateCache((key, type) =>
removeAssetNames.Contains(key)
|| (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName))
)
.Select(p => p.Item1)
.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase)
.ToArray();
if (invalidated.Any())
this.Monitor.Log($"Invalidated {invalidated.Length} asset names: {string.Join(", ", invalidated)} for locale change.", LogLevel.Trace);
}
/// Create a new content manager for temporary use.
public override LocalizedContentManager CreateTemporary()
{
return this.Coordinator.CreateGameContentManager("(temporary)");
}
/*********
** Private methods
*********/
/// Get whether an asset has already been loaded.
/// The normalised asset name.
protected override bool IsNormalisedKeyLoaded(string normalisedAssetName)
{
// default English
if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalisedAssetName))
return this.Cache.ContainsKey(normalisedAssetName);
// translated
string keyWithLocale = $"{normalisedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}";
if (this.IsLocalisableLookup.TryGetValue(keyWithLocale, out bool localisable))
{
return localisable
? this.Cache.ContainsKey(keyWithLocale)
: this.Cache.ContainsKey(normalisedAssetName);
}
// not loaded yet
return false;
}
/// Parse an asset key that contains an explicit language into its asset name and language, if applicable.
/// The asset key to parse.
/// The asset name without the language code.
/// The language code removed from the asset name.
/// Returns whether the asset key contains an explicit language and was successfully parsed.
private bool TryParseExplicitLanguageAssetKey(string rawAsset, out string assetName, out LanguageCode language)
{
if (string.IsNullOrWhiteSpace(rawAsset))
throw new SContentLoadException("The asset key is empty.");
// extract language code
int splitIndex = rawAsset.LastIndexOf('.');
if (splitIndex != -1 && this.LanguageCodes.TryGetValue(rawAsset.Substring(splitIndex + 1), out language))
{
assetName = rawAsset.Substring(0, splitIndex);
return true;
}
// no explicit language code found
assetName = rawAsset;
language = this.Language;
return false;
}
/// Load the initial asset from the registered .
/// The basic asset metadata.
/// Returns the loaded asset metadata, or null if no loader matched.
private IAssetData ApplyLoader(IAssetInfo info)
{
// find matching loaders
var loaders = this.GetInterceptors(this.Loaders)
.Where(entry =>
{
try
{
return entry.Value.CanLoad(info);
}
catch (Exception ex)
{
entry.Key.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
return false;
}
})
.ToArray();
// validate loaders
if (!loaders.Any())
return null;
if (loaders.Length > 1)
{
string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray();
this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn);
return null;
}
// fetch asset from loader
IModMetadata mod = loaders[0].Key;
IAssetLoader loader = loaders[0].Value;
T data;
try
{
data = this.CloneIfPossible(loader.Load(info));
this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace);
}
catch (Exception ex)
{
mod.LogAsMod($"Mod crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
return null;
}
// validate asset
if (data == null)
{
mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error);
return null;
}
// return matched asset
return new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName);
}
/// Apply any to a loaded asset.
/// The asset type.
/// The basic asset metadata.
/// The loaded asset.
private IAssetData ApplyEditors(IAssetInfo info, IAssetData asset)
{
IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName);
// edit asset
foreach (var entry in this.GetInterceptors(this.Editors))
{
// check for match
IModMetadata mod = entry.Key;
IAssetEditor editor = entry.Value;
try
{
if (!editor.CanEdit(info))
continue;
}
catch (Exception ex)
{
mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
continue;
}
// try edit
object prevAsset = asset.Data;
try
{
editor.Edit(asset);
this.Monitor.Log($"{mod.DisplayName} edited {info.AssetName}.", LogLevel.Trace);
}
catch (Exception ex)
{
mod.LogAsMod($"Mod crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
// validate edit
if (asset.Data == null)
{
mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn);
asset = GetNewData(prevAsset);
}
else if (!(asset.Data is T))
{
mod.LogAsMod($"Mod incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn);
asset = GetNewData(prevAsset);
}
}
// return result
return asset;
}
/// Get all registered interceptors from a list.
private IEnumerable> GetInterceptors(IDictionary> entries)
{
foreach (var entry in entries)
{
IModMetadata mod = entry.Key;
IList interceptors = entry.Value;
// registered editors
foreach (T interceptor in interceptors)
yield return new KeyValuePair(mod, interceptor);
}
}
}
}