#nullable disable
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 to automatically try resolving keys to a localized form if available.
protected bool TryLocalizeKeys = true;
/// 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;
/// A list of disposable assets.
private readonly List> Disposables = new();
/// 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
*********/
///
public string Name { get; }
///
public LanguageCode Language => this.GetCurrentLanguage();
///
public string FullRootDirectory => Path.Combine(Constants.GamePath, this.RootDirectory);
///
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.BaseDisposableReferences = reflection.GetField>(this, "disposableAssets").GetValue();
}
///
public virtual bool DoesAssetExist(IAssetName assetName)
{
return this.Cache.ContainsKey(assetName.Name);
}
///
[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 sealed override T LoadBase(string assetName)
{
return this.Load(assetName, LanguageCode.en);
}
///
public sealed override T Load(string assetName)
{
return this.Load(assetName, this.Language);
}
///
public sealed override T Load(string assetName, LanguageCode language)
{
assetName = this.PrenormalizeRawAssetName(assetName);
IAssetName parsedName = this.Coordinator.ParseAssetName(assetName, allowLocales: this.TryLocalizeKeys);
return this.LoadLocalized(parsedName, language, useCache: true);
}
///
public T LoadLocalized(IAssetName assetName, LanguageCode language, bool useCache)
{
// ignore locale in English (or if disabled)
if (!this.TryLocalizeKeys || language == LocalizedContentManager.LanguageCode.en)
return this.LoadExact(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
{
T data = this.LoadExact(localizedName, useCache: useCache);
LocalizedContentManager.localizedAssetNames[assetName.Name] = localizedName.Name;
return data;
}
catch (ContentLoadException)
{
localizedName = new AssetName(assetName.BaseName + "_international", null, null);
try
{
T data = this.LoadExact(localizedName, useCache: useCache);
LocalizedContentManager.localizedAssetNames[assetName.Name] = localizedName.Name;
return data;
}
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(rawName, allowLocales: this.TryLocalizeKeys);
return this.LoadExact(assetName, useCache: useCache);
}
///
public abstract T LoadExact(IAssetName assetName, bool useCache);
///
[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
****/
///
public string GetLocale()
{
return this.GetLocale(this.GetCurrentLanguage());
}
///
public string GetLocale(LanguageCode language)
{
return this.LanguageCodeString(language);
}
///
public bool IsLoaded(IAssetName assetName)
{
return this.Cache.ContainsKey(assetName.Name);
}
/****
** Cache invalidation
****/
///
public IDictionary InvalidateCache(Func predicate, bool dispose = false)
{
IDictionary removeAssets = new Dictionary(StringComparer.OrdinalIgnoreCase);
this.Cache.Remove((key, asset) =>
{
string baseAssetName = this.Coordinator.ParseAssetName(key, allowLocales: this.TryLocalizeKeys).BaseName;
// check if asset should be removed
bool remove = removeAssets.ContainsKey(baseAssetName);
if (!remove && predicate(baseAssetName, asset.GetType()))
{
removeAssets[baseAssetName] = asset;
remove = true;
}
// dispose if safe
if (remove && this.AggressiveMemoryOptimizations)
{
if (asset is Map map)
map.DisposeTileSheets(Game1.mapDisplayDevice);
}
return remove;
}, dispose);
return removeAssets;
}
///
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
*********/
/// Apply initial normalization to a raw asset name before it's parsed.
/// The asset name to normalize.
private string PrenormalizeRawAssetName(string assetName)
{
// trim
assetName = assetName?.Trim();
// For legacy reasons, mods can pass .xnb file extensions to the content pipeline which
// are then stripped. This will be re-added as needed when reading from raw files.
if (assetName?.EndsWith(".xnb") == true)
assetName = assetName[..^".xnb".Length];
return assetName;
}
/// Normalize path separators in a file path. For asset keys, see instead.
/// The file path to normalize.
[Pure]
protected string NormalizePathSeparators(string path)
{
return this.Cache.NormalizePathSeparators(path);
}
/// 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(IAssetName assetName, bool useCache)
{
return useCache
? base.LoadBase(assetName.Name)
: this.ReadAsset(assetName.Name, 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.
/// Whether to save the asset to the asset cache.
protected virtual void TrackAsset(IAssetName assetName, T value, bool useCache)
{
// track asset key
if (value is Texture2D texture)
texture.Name = assetName.Name;
// 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;
// avoid hard disposable references; see remarks on the field
this.BaseDisposableReferences.Clear();
}
}
}