using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Serialisation;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
namespace StardewModdingAPI.Framework
{
/// The central logic for creating content managers, invalidating caches, and propagating asset changes.
internal class ContentCoordinator : IDisposable
{
/*********
** Fields
*********/
/// An asset key prefix for assets from SMAPI mod folders.
private readonly string ManagedPrefix = "SMAPI";
/// Encapsulates monitoring and logging.
private readonly IMonitor Monitor;
/// Provides metadata for core game assets.
private readonly CoreAssetPropagator CoreAssets;
/// Simplifies access to private code.
private readonly Reflector Reflection;
/// Encapsulates SMAPI's JSON file parsing.
private readonly JsonHelper JsonHelper;
/// A callback to invoke the first time *any* game content manager loads an asset.
private readonly Action OnLoadingFirstAsset;
/// The loaded content managers (including the ).
private readonly IList ContentManagers = new List();
/// The language code for language-agnostic mod assets.
private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage;
/// Whether the content coordinator has been disposed.
private bool IsDisposed;
/*********
** Accessors
*********/
/// The primary content manager used for most assets.
public GameContentManager MainContentManager { get; private set; }
/// The current language as a constant.
public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language;
/// Interceptors which provide the initial versions of matching assets.
public IDictionary> Loaders { get; } = new Dictionary>();
/// Interceptors which edit matching assets after they're loaded.
public IDictionary> Editors { get; } = new Dictionary>();
/// The absolute path to the .
public string FullRootDirectory { get; }
/*********
** Public methods
*********/
/// Construct an instance.
/// The service provider to use to locate services.
/// The root directory to search for content.
/// The current culture for which to localise content.
/// Encapsulates monitoring and logging.
/// Simplifies access to private code.
/// Encapsulates SMAPI's JSON file parsing.
/// A callback to invoke the first time *any* game content manager loads an asset.
public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset)
{
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.Reflection = reflection;
this.JsonHelper = jsonHelper;
this.OnLoadingFirstAsset = onLoadingFirstAsset;
this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory);
this.ContentManagers.Add(
this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing, onLoadingFirstAsset)
);
this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormaliseAssetName, reflection, monitor);
}
/// Get a new content manager which handles reading files from the game content folder with support for interception.
/// A name for the mod manager. Not guaranteed to be unique.
public GameContentManager CreateGameContentManager(string name)
{
GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset);
this.ContentManagers.Add(manager);
return manager;
}
/// Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.
/// A name for the mod manager. Not guaranteed to be unique.
/// The root directory to search for content (or null for the default).
/// The game content manager used for map tilesheets not provided by the mod.
public ModContentManager CreateModContentManager(string name, string rootDirectory, IContentManager gameContentManager)
{
ModContentManager manager = new ModContentManager(
name: name,
gameContentManager: gameContentManager,
serviceProvider: this.MainContentManager.ServiceProvider,
rootDirectory: rootDirectory,
currentCulture: this.MainContentManager.CurrentCulture,
coordinator: this,
monitor: this.Monitor,
reflection: this.Reflection,
jsonHelper: this.JsonHelper,
onDisposing: this.OnDisposing
);
this.ContentManagers.Add(manager);
return manager;
}
/// Get the current content locale.
public string GetLocale()
{
return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode);
}
/// Perform any cleanup needed when the locale changes.
public void OnLocaleChanged()
{
foreach (IContentManager contentManager in this.ContentManagers)
contentManager.OnLocaleChanged();
}
/// Get whether this asset is mapped to a mod folder.
/// The asset key.
public bool IsManagedAssetKey(string key)
{
return key.StartsWith(this.ManagedPrefix);
}
/// Parse a managed SMAPI asset key which maps to a mod folder.
/// The asset key.
/// The unique name for the content manager which should load this asset.
/// The relative path within the mod folder.
/// Returns whether the asset was parsed successfully.
public bool TryParseManagedAssetKey(string key, out string contentManagerID, out string relativePath)
{
contentManagerID = null;
relativePath = null;
// not a managed asset
if (!key.StartsWith(this.ManagedPrefix))
return false;
// parse
string[] parts = PathUtilities.GetSegments(key, 3);
if (parts.Length != 3) // managed key prefix, mod id, relative path
return false;
contentManagerID = Path.Combine(parts[0], parts[1]);
relativePath = parts[2];
return true;
}
/// Get the managed asset key prefix for a mod.
/// The mod's unique ID.
public string GetManagedAssetPrefix(string modID)
{
return Path.Combine(this.ManagedPrefix, modID.ToLower());
}
/// Get a copy of an asset from a mod folder.
/// The asset type.
/// The unique name for the content manager which should load this asset.
/// The internal SMAPI asset key.
public T LoadManagedAsset(string contentManagerID, string relativePath)
{
// get content manager
IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID);
if (contentManager == null)
throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod.");
// get fresh asset
return contentManager.Load(relativePath, this.DefaultLanguage, useCache: false);
}
/// Purge assets from the cache that match one of the interceptors.
/// The asset editors for which to purge matching assets.
/// The asset loaders for which to purge matching assets.
/// Returns the invalidated asset names.
public IEnumerable InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders)
{
if (!editors.Any() && !loaders.Any())
return new string[0];
// get CanEdit/Load methods
MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit));
MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad));
if (canEdit == null || canLoad == null)
throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen
// invalidate matching keys
return this.InvalidateCache(asset =>
{
// check loaders
MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType);
foreach (IAssetLoader loader in loaders)
{
try
{
if ((bool)canLoadGeneric.Invoke(loader, new object[] { asset }))
return true;
}
catch (Exception ex)
{
this.GetModFor(loader).LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
// check editors
MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType);
foreach (IAssetEditor editor in editors)
{
try
{
if ((bool)canEditGeneric.Invoke(editor, new object[] { asset }))
return true;
}
catch (Exception ex)
{
this.GetModFor(editor).LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
// asset not affected by a loader or editor
return false;
});
}
/// Purge matched assets from the cache.
/// Matches the asset keys to invalidate.
/// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game.
/// Returns the invalidated asset keys.
public IEnumerable InvalidateCache(Func predicate, bool dispose = false)
{
string locale = this.GetLocale();
return this.InvalidateCache((assetName, type) =>
{
IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormaliseAssetName);
return predicate(info);
}, dispose);
}
/// Purge matched assets from the cache.
/// Matches the asset keys to invalidate.
/// Whether to dispose invalidated assets. This should only be true when they're being invalidated as part of a dispose, to avoid crashing the game.
/// Returns the invalidated asset names.
public IEnumerable InvalidateCache(Func predicate, bool dispose = false)
{
// invalidate cache
IDictionary removedAssetNames = new Dictionary(StringComparer.InvariantCultureIgnoreCase);
foreach (IContentManager contentManager in this.ContentManagers)
{
foreach (Tuple asset in contentManager.InvalidateCache(predicate, dispose))
removedAssetNames[asset.Item1] = asset.Item2;
}
// reload core game assets
int reloaded = this.CoreAssets.Propagate(this.MainContentManager, removedAssetNames); // use an intercepted content manager
// report result
if (removedAssetNames.Any())
this.Monitor.Log($"Invalidated {removedAssetNames.Count} asset names: {string.Join(", ", removedAssetNames.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
else
this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
return removedAssetNames.Keys;
}
/// Dispose held resources.
public void Dispose()
{
if (this.IsDisposed)
return;
this.IsDisposed = true;
this.Monitor.Log("Disposing the content coordinator. Content managers will no longer be usable after this point.", LogLevel.Trace);
foreach (IContentManager contentManager in this.ContentManagers)
contentManager.Dispose();
this.ContentManagers.Clear();
this.MainContentManager = null;
}
/*********
** Private methods
*********/
/// A callback invoked when a content manager is disposed.
/// The content manager being disposed.
private void OnDisposing(IContentManager contentManager)
{
if (this.IsDisposed)
return;
this.ContentManagers.Remove(contentManager);
}
/// Get the mod which registered an asset loader.
/// The asset loader.
/// The given loader couldn't be matched to a mod.
private IModMetadata GetModFor(IAssetLoader loader)
{
foreach (var pair in this.Loaders)
{
if (pair.Value.Contains(loader))
return pair.Key;
}
throw new KeyNotFoundException("This loader isn't associated with a known mod.");
}
/// Get the mod which registered an asset editor.
/// The asset editor.
/// The given editor couldn't be matched to a mod.
private IModMetadata GetModFor(IAssetEditor editor)
{
foreach (var pair in this.Editors)
{
if (pair.Value.Contains(editor))
return pair.Key;
}
throw new KeyNotFoundException("This editor isn't associated with a known mod.");
}
}
}