using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
using StardewValley.GameData;
using xTile;
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";
/// Whether to enable more aggressive memory optimizations.
private readonly bool AggressiveMemoryOptimizations;
/// 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 List ContentManagers = new();
/// 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;
/// A lock used to prevent asynchronous changes to the content manager list.
/// The game may add content managers in asynchronous threads (e.g. when populating the load screen).
private readonly ReaderWriterLockSlim ContentManagerLock = new();
/// A cache of ordered tilesheet IDs used by vanilla maps.
private readonly Dictionary VanillaTilesheets = new(StringComparer.OrdinalIgnoreCase);
/// An unmodified content manager which doesn't intercept assets, used to compare asset data.
private readonly LocalizedContentManager VanillaContentManager;
/// The language enum values indexed by locale code.
private Lazy> LocaleCodes;
/*********
** 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 IList> Loaders { get; } = new List>();
/// Interceptors which edit matching assets after they're loaded.
public IList> Editors { get; } = new List>();
/// 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 localize 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.
/// Whether to enable more aggressive memory optimizations.
public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations)
{
this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations;
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.Reflection = reflection;
this.JsonHelper = jsonHelper;
this.OnLoadingFirstAsset = onLoadingFirstAsset;
this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory);
this.ContentManagers.Add(
this.MainContentManager = new GameContentManager(
name: "Game1.content",
serviceProvider: serviceProvider,
rootDirectory: rootDirectory,
currentCulture: currentCulture,
coordinator: this,
monitor: monitor,
reflection: reflection,
onDisposing: this.OnDisposing,
onLoadingFirstAsset: onLoadingFirstAsset,
aggressiveMemoryOptimizations: aggressiveMemoryOptimizations
)
);
var contentManagerForAssetPropagation = new GameContentManagerForAssetPropagation(
name: nameof(GameContentManagerForAssetPropagation),
serviceProvider: serviceProvider,
rootDirectory: rootDirectory,
currentCulture: currentCulture,
coordinator: this,
monitor: monitor,
reflection: reflection,
onDisposing: this.OnDisposing,
onLoadingFirstAsset: onLoadingFirstAsset,
aggressiveMemoryOptimizations: aggressiveMemoryOptimizations
);
this.ContentManagers.Add(contentManagerForAssetPropagation);
this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory);
this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations);
this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(includeCustomLanguages: false));
}
/// 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)
{
return this.ContentManagerLock.InWriteLock(() =>
{
GameContentManager manager = new(
name: name,
serviceProvider: this.MainContentManager.ServiceProvider,
rootDirectory: this.MainContentManager.RootDirectory,
currentCulture: this.MainContentManager.CurrentCulture,
coordinator: this,
monitor: this.Monitor,
reflection: this.Reflection,
onDisposing: this.OnDisposing,
onLoadingFirstAsset: this.OnLoadingFirstAsset,
aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations
);
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 mod display name to show in errors.
/// 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 modName, string rootDirectory, IContentManager gameContentManager)
{
return this.ContentManagerLock.InWriteLock(() =>
{
ModContentManager manager = new(
name: name,
gameContentManager: gameContentManager,
serviceProvider: this.MainContentManager.ServiceProvider,
rootDirectory: rootDirectory,
modName: modName,
currentCulture: this.MainContentManager.CurrentCulture,
coordinator: this,
monitor: this.Monitor,
reflection: this.Reflection,
jsonHelper: this.JsonHelper,
onDisposing: this.OnDisposing,
aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations
);
this.ContentManagers.Add(manager);
return manager;
});
}
/// Get the current content locale.
public string GetLocale()
{
return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode);
}
/// Perform any updates needed when the game loads custom languages from Data/AdditionalLanguages.
public void OnAdditionalLanguagesInitialized()
{
// update locale cache for custom languages, and load it now (since languages added later won't work)
this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(includeCustomLanguages: true));
_ = this.LocaleCodes.Value;
}
/// Perform any updates needed when the locale changes.
public void OnLocaleChanged()
{
// reload affected content
this.ContentManagerLock.InReadLock(() =>
{
foreach (IContentManager contentManager in this.ContentManagers)
contentManager.OnLocaleChanged();
this.VanillaContentManager.Unload();
});
}
/// Clean up when the player is returning to the title screen.
/// This is called after the player returns to the title screen, but before runs.
public void OnReturningToTitleScreen()
{
// The game clears LocalizedContentManager.localizedAssetNames after returning to the title screen. That
// causes an inconsistency in the SMAPI asset cache, which leads to an edge case where assets already
// provided by mods via IAssetLoader when playing in non-English are ignored.
//
// For example, let's say a mod provides the 'Data\mail' asset through IAssetLoader when playing in
// Portuguese. Here's the normal load process after it's loaded:
// 1. The game requests Data\mail.
// 2. SMAPI sees that it's already cached, and calls LoadRaw to bypass asset interception.
// 3. LoadRaw sees that there's a localized key mapping, and gets the mapped key.
// 4. In this case "Data\mail" is mapped to "Data\mail" since it was loaded by a mod, so it loads that
// asset.
//
// When the game clears localizedAssetNames, that process goes wrong in step 4:
// 3. LoadRaw sees that there's no localized key mapping *and* the locale is non-English, so it attempts
// to load from the localized key format.
// 4. In this case that's 'Data\mail.pt-BR', so it successfully loads that asset.
// 5. Since we've bypassed asset interception at this point, it's loaded directly from the base content
// manager without mod changes.
//
// To avoid issues, we just remove affected assets from the cache here so they'll be reloaded normally.
// Note that we *must* propagate changes here, otherwise when mods invalidate the cache later to reapply
// their changes, the assets won't be found in the cache so no changes will be propagated.
if (LocalizedContentManager.CurrentLanguageCode != LocalizedContentManager.LanguageCode.en)
this.InvalidateCache((contentManager, key, type) => contentManager is GameContentManager);
}
/// Parse a raw asset name.
/// The raw asset name to parse.
/// The is null or empty.
public AssetName ParseAssetName(string rawName)
{
return !string.IsNullOrWhiteSpace(rawName)
? AssetName.Parse(rawName, parseLocale: locale => this.LocaleCodes.Value.TryGetValue(locale, out LocalizedContentManager.LanguageCode langCode) ? langCode : null)
: throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName));
}
/// 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.ContentManagerLock.InReadLock(() =>
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 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((contentManager, rawName, type) =>
{
IAssetName assetName = this.ParseAssetName(rawName);
IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName);
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 & track removed assets
IDictionary removedAssets = new Dictionary();
this.ContentManagerLock.InReadLock(() =>
{
// cached assets
foreach (IContentManager contentManager in this.ContentManagers)
{
foreach (var entry in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose))
{
AssetName assetName = this.ParseAssetName(entry.Key);
if (!removedAssets.ContainsKey(assetName))
removedAssets[assetName] = entry.Value.GetType();
}
}
// special case: maps may be loaded through a temporary content manager that's removed while the map is still in use.
// This notably affects the town and farmhouse maps.
if (Game1.locations != null)
{
foreach (GameLocation location in Game1.locations)
{
if (location.map == null || string.IsNullOrWhiteSpace(location.mapPath.Value))
continue;
// get map path
AssetName mapPath = this.ParseAssetName(this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value));
if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map)))
removedAssets[mapPath] = typeof(Map);
}
}
});
// reload core game assets
if (removedAssets.Any())
{
// propagate changes to the game
this.CoreAssets.Propagate(
assets: removedAssets.ToDictionary(p => p.Key, p => p.Value),
ignoreWorld: Context.IsWorldFullyUnloaded,
out IDictionary propagated,
out bool updatedNpcWarps
);
// log summary
StringBuilder report = new();
{
IAssetName[] invalidatedKeys = removedAssets.Keys.ToArray();
IAssetName[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray();
string FormatKeyList(IEnumerable keys) => string.Join(", ", keys.Select(p => p.Name).OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
report.AppendLine($"Invalidated {invalidatedKeys.Length} asset names ({FormatKeyList(invalidatedKeys)}).");
report.AppendLine(propagated.Count > 0
? $"Propagated {propagatedKeys.Length} core assets ({FormatKeyList(propagatedKeys)})."
: "Propagated 0 core assets."
);
if (updatedNpcWarps)
report.AppendLine("Updated NPC pathfinding cache.");
}
this.Monitor.Log(report.ToString().TrimEnd());
}
else
this.Monitor.Log("Invalidated 0 cache entries.");
return removedAssets.Keys;
}
/// Get all loaded instances of an asset name.
/// The asset name.
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")]
public IEnumerable