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;
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;
/// Simplifies access to private code.
protected readonly Reflector Reflection;
/// 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).
protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action onDisposing, bool isNamespaced)
: base(serviceProvider, rootDirectory, currentCulture)
{
// init
this.Name = name;
this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
// ReSharper disable once VirtualMemberCallInConstructor -- LoadedAssets isn't overridden by SMAPI or Stardew Valley
this.Cache = new ContentCache(this.LoadedAssets);
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.Reflection = reflection;
this.OnDisposing = onDisposing;
this.IsNamespaced = isNamespaced;
// get asset data
this.BaseDisposableReferences = reflection.GetField?>(this, "disposableAssets").GetValue()
?? throw new InvalidOperationException("Can't initialize content manager: the required 'disposableAssets' field wasn't found.");
}
///
public virtual bool DoesAssetExist(IAssetName assetName)
where T : notnull
{
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);
}
///
[SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Copied as-is from game code")]
public sealed override string LoadBaseString(string path)
{
try
{
// copied as-is from LocalizedContentManager.LoadBaseString
// This is only changed to call this.Load instead of base.Load, to support mod assets
this.ParseStringPath(path, out string assetName, out string key);
Dictionary strings = this.Load>(assetName, LanguageCode.en);
return strings != null && strings.ContainsKey(key)
? this.GetString(strings, key)
: path;
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed loading string path '{path}' from '{this.Name}'.", ex);
}
}
///
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)
where T : notnull
{
// ignore locale in English (or if disabled)
if (!this.TryLocalizeKeys || language == LocalizedContentManager.LanguageCode.en)
return this.LoadExact(assetName, useCache: useCache);
// check for localized asset
// ReSharper disable once LocalVariableHidesMember -- this is deliberate
Dictionary localizedAssetNames = this.Coordinator.LocalizedAssetNames.Value;
if (!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);
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);
localizedAssetNames[assetName.Name] = localizedName.Name;
return data;
}
catch (ContentLoadException)
{
localizedAssetNames[assetName.Name] = assetName.Name;
}
}
}
// use cached key
string rawName = 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)
where T : notnull;
///
[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(ContentLoadErrorType.InvalidName, "The asset key or local path is empty.");
if (assetName.Intersect(Path.GetInvalidPathChars()).Any())
throw new SContentLoadException(ContentLoadErrorType.InvalidName, "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 IEnumerable> GetCachedAssets()
{
foreach (string key in this.Cache.Keys)
yield return new(key, this.Cache[key]);
}
///
public bool InvalidateCache(IAssetName assetName, bool dispose = false)
{
if (!this.Cache.ContainsKey(assetName.Name))
return false;
// remove from cache
this.Cache.Remove(assetName.Name, dispose);
return true;
}
///
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.
[return: NotNullIfNotNull("assetName")]
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]
[return: NotNullIfNotNull("path")]
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)
where T : notnull
{
// 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();
}
/****
** Private methods copied from the game code
****/
#pragma warning disable CS1574 // can't be resolved: the reference is valid but private
/// Parse a string path like assetName:key.
/// The string path.
/// The extracted asset name.
/// The extracted entry key.
/// The string path is not in a valid format.
/// This is copied as-is from .
private void ParseStringPath(string path, out string assetName, out string key)
{
int length = path.IndexOf(':');
assetName = length != -1 ? path.Substring(0, length) : throw new ContentLoadException("Unable to parse string path: " + path);
key = path.Substring(length + 1, path.Length - length - 1);
}
/// Get a string value from a dictionary asset.
/// The asset to read.
/// The string key to find.
/// This is copied as-is from .
private string GetString(Dictionary strings, string key)
{
return strings.TryGetValue(key + ".desktop", out string? str)
? str
: strings[key];
}
#pragma warning restore CS1574
}
}