using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
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;
using StardewModdingAPI.Framework.Reflection;
using StardewValley;
using xTile;
namespace StardewModdingAPI.Framework.ContentManagers
{
/// A content manager which handles reading files from a SMAPI mod folder with support for unpacked files.
internal abstract class BaseContentManager : LocalizedContentManager, IContentManager
{
/*********
** Fields
*********/
/// The central coordinator which manages content managers.
protected readonly ContentCoordinator Coordinator;
/// The underlying asset cache.
protected readonly ContentCache Cache;
/// Encapsulates monitoring and logging.
protected readonly IMonitor Monitor;
/// Whether to enable more aggressive memory optimizations.
protected readonly bool AggressiveMemoryOptimizations;
/// Whether the content coordinator has been disposed.
private bool IsDisposed;
/// A callback to invoke when the content manager is being disposed.
private readonly Action OnDisposing;
/// The language enum values indexed by locale code.
protected IDictionary LanguageCodes { get; }
/// A list of disposable assets.
private readonly List> Disposables = new List>();
/// The disposable assets tracked by the base content manager.
/// 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 instead, which avoids a hard reference.
private readonly List BaseDisposableReferences;
/*********
** Accessors
*********/
/// A name for the mod manager. Not guaranteed to be unique.
public string Name { get; }
/// The current language as a constant.
public LanguageCode Language => this.GetCurrentLanguage();
/// The absolute path to the .
public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory);
/// Whether this content manager can be targeted by managed asset keys (e.g. to load assets from a mod folder).
public bool IsNamespaced { get; }
/*********
** 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 localize 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.
/// Whether this content manager handles managed asset keys (e.g. to load assets from a mod folder).
/// Whether to enable more aggressive memory optimizations.
protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action onDisposing, bool isNamespaced, bool aggressiveMemoryOptimizations)
: base(serviceProvider, rootDirectory, currentCulture)
{
// init
this.Name = name;
this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
this.Cache = new ContentCache(this, reflection);
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.OnDisposing = onDisposing;
this.IsNamespaced = isNamespaced;
this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations;
// get asset data
this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.OrdinalIgnoreCase);
this.BaseDisposableReferences = reflection.GetField>(this, "disposableAssets").GetValue();
}
/// 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.
public override T Load(string assetName)
{
return this.Load(assetName, this.Language, useCache: true);
}
/// 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)
{
return this.Load(assetName, language, useCache: true);
}
/// 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.
/// Whether to read/write the loaded asset to the asset cache.
public abstract T Load(string assetName, LocalizedContentManager.LanguageCode language, bool useCache);
/// Load the base asset without localization.
/// The type of asset to load.
/// The asset path relative to the loader root directory, not including the .xnb extension.
[Obsolete("This method is implemented for the base game and should not be used directly. To load an asset from the underlying content manager directly, use " + nameof(BaseContentManager.RawLoad) + " instead.")]
public override T LoadBase(string assetName)
{
return this.Load(assetName, LanguageCode.en, useCache: true);
}
/// Perform any cleanup needed when the locale changes.
public virtual void OnLocaleChanged() { }
/// Normalize path separators in a file path. For asset keys, see instead.
/// The file path to normalize.
[Pure]
public string NormalizePathSeparators(string path)
{
return this.Cache.NormalizePathSeparators(path);
}
/// Assert that the given key has a valid format and return a normalized form consistent with the underlying cache.
/// The asset key to check.
/// The asset key is empty or contains invalid characters.
[SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")]
public string AssertAndNormalizeAssetName(string assetName)
{
// NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid
// throwing other types like ArgumentException here.
if (string.IsNullOrWhiteSpace(assetName))
throw new SContentLoadException("The asset key or local path is empty.");
if (assetName.Intersect(Path.GetInvalidPathChars()).Any())
throw new SContentLoadException("The asset key or local path contains invalid characters.");
return this.Cache.NormalizeKey(assetName);
}
/****
** Content loading
****/
/// Get the current content locale.
public string GetLocale()
{
return this.GetLocale(this.GetCurrentLanguage());
}
/// The locale for a language.
/// The language.
public string GetLocale(LanguageCode language)
{
return this.LanguageCodeString(language);
}
/// Get whether the content manager has already loaded and cached the given asset.
/// The asset path relative to the loader root directory, not including the .xnb extension.
/// The language.
public bool IsLoaded(string assetName, LanguageCode language)
{
assetName = this.Cache.NormalizeKey(assetName);
return this.IsNormalizedKeyLoaded(assetName, language);
}
/// Get the cached asset keys.
public IEnumerable GetAssetKeys()
{
return this.Cache.Keys
.Select(this.GetAssetName)
.Distinct();
}
/****
** Cache invalidation
****/
/// 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 and instances.
public IDictionary InvalidateCache(Func predicate, bool dispose = false)
{
IDictionary removeAssets = new Dictionary(StringComparer.OrdinalIgnoreCase);
this.Cache.Remove((key, asset) =>
{
this.ParseCacheKey(key, out string assetName, out _);
// check if asset should be removed
bool remove = removeAssets.ContainsKey(assetName);
if (!remove && predicate(assetName, asset.GetType()))
{
removeAssets[assetName] = asset;
remove = true;
}
// dispose if safe
if (remove && this.AggressiveMemoryOptimizations)
{
if (asset is Map map)
map.DisposeTileSheets(Game1.mapDisplayDevice);
}
return remove;
}, dispose);
return removeAssets;
}
/// Dispose held resources.
/// Whether the content manager is being disposed (rather than finalized).
protected override void Dispose(bool isDisposing)
{
// ignore if disposed
if (this.IsDisposed)
return;
this.IsDisposed = true;
// dispose uncached assets
foreach (WeakReference reference in this.Disposables)
{
if (reference.TryGetTarget(out IDisposable disposable))
{
try
{
disposable.Dispose();
}
catch { /* ignore dispose errors */ }
}
}
this.Disposables.Clear();
// raise event
this.OnDisposing(this);
base.Dispose(isDisposing);
}
///
public override void Unload()
{
if (this.IsDisposed)
return; // base logic doesn't allow unloading twice, which happens due to SMAPI and the game both unloading
base.Unload();
}
/*********
** Private methods
*********/
/// Load an asset file directly from the underlying content manager.
/// The type of asset to load.
/// The normalized asset key.
/// Whether to read/write the loaded asset to the asset cache.
protected virtual T RawLoad(string assetName, bool useCache)
{
return useCache
? base.LoadBase(assetName)
: base.ReadAsset(assetName, disposable => this.Disposables.Add(new WeakReference(disposable)));
}
/// Add tracking data to an asset and add it to 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.
/// Whether to save the asset to the asset cache.
protected virtual void TrackAsset(string assetName, T value, LanguageCode language, bool useCache)
{
// track asset key
if (value is Texture2D texture)
texture.Name = assetName;
// cache asset
if (useCache)
{
assetName = this.AssertAndNormalizeAssetName(assetName);
this.Cache[assetName] = value;
}
// avoid hard disposable references; see remarks on the field
this.BaseDisposableReferences.Clear();
}
/// Parse a cache key into its component parts.
/// The input cache key.
/// The original asset name.
/// The asset locale code (or null if not localized).
protected void ParseCacheKey(string cacheKey, out string assetName, out string localeCode)
{
// handle localized key
if (!string.IsNullOrWhiteSpace(cacheKey))
{
int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.Ordinal);
if (lastSepIndex >= 0)
{
string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
if (this.LanguageCodes.ContainsKey(suffix))
{
assetName = cacheKey.Substring(0, lastSepIndex);
localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1);
return;
}
}
}
// handle simple key
assetName = cacheKey;
localeCode = null;
}
/// Get whether an asset has already been loaded.
/// The normalized asset name.
/// The language to check.
protected abstract bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language);
/// Get the locale codes (like ja-JP) used in asset keys.
private IDictionary GetKeyLocales()
{
// create locale => code map
IDictionary map = new Dictionary();
foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode)))
map[code] = this.GetLocale(code);
return map;
}
/// Get the asset name from a cache key.
/// The input cache key.
private string GetAssetName(string cacheKey)
{
this.ParseCacheKey(cacheKey, out string assetName, out string _);
return assetName;
}
}
}