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.Events;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Internal;
using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Toolkit.Utilities.PathLookups;
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";
/// Get a file lookup for the given directory.
private readonly Func GetFileLookup;
/// 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;
/// A callback to invoke when an asset is fully loaded.
private readonly Action OnAssetLoaded;
/// A callback to invoke when any asset names have been invalidated from the cache.
private readonly Action> OnAssetsInvalidated;
/// Get the load/edit operations to apply to an asset by querying registered event handlers.
private readonly Func RequestAssetOperations;
/// The loaded content managers (including the ).
private readonly List ContentManagers = new();
/// 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;
/// The cached asset load/edit operations to apply, indexed by asset name.
private readonly TickCacheDictionary AssetOperationsByKey = new();
/// A cache of asset operation groups created for legacy implementations.
[Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")]
private readonly Dictionary> LegacyLoaderCache = new(ReferenceEqualityComparer.Instance);
/// A cache of asset operation groups created for legacy implementations.
[Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")]
private readonly Dictionary> LegacyEditorCache = new(ReferenceEqualityComparer.Instance);
/*********
** 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.
[Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")]
public IList> Loaders { get; } = new List>();
/// Interceptors which edit matching assets after they're loaded.
[Obsolete("This only exists to support legacy code and will be removed in SMAPI 4.0.0.")]
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.
/// A callback to invoke when an asset is fully loaded.
/// Get a file lookup for the given directory.
/// A callback to invoke when any asset names have been invalidated from the cache.
/// Get the load/edit operations to apply to an asset by querying registered event handlers.
public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action onAssetLoaded, Func getFileLookup, Action> onAssetsInvalidated, Func requestAssetOperations)
{
this.GetFileLookup = getFileLookup;
this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor));
this.Reflection = reflection;
this.JsonHelper = jsonHelper;
this.OnLoadingFirstAsset = onLoadingFirstAsset;
this.OnAssetLoaded = onAssetLoaded;
this.OnAssetsInvalidated = onAssetsInvalidated;
this.RequestAssetOperations = requestAssetOperations;
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,
onAssetLoaded: onAssetLoaded
)
);
var contentManagerForAssetPropagation = new GameContentManagerForAssetPropagation(
name: nameof(GameContentManagerForAssetPropagation),
serviceProvider: serviceProvider,
rootDirectory: rootDirectory,
currentCulture: currentCulture,
coordinator: this,
monitor: monitor,
reflection: reflection,
onDisposing: this.OnDisposing,
onLoadingFirstAsset: onLoadingFirstAsset,
onAssetLoaded: onAssetLoaded
);
this.ContentManagers.Add(contentManagerForAssetPropagation);
this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory);
this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, name => this.ParseAssetName(name, allowLocales: true));
this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(customLanguages: Enumerable.Empty()));
}
/// 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,
onAssetLoaded: this.OnAssetLoaded
);
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,
fileLookup: this.GetFileLookup(rootDirectory)
);
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)
var customLanguages = this.MainContentManager.Load>("Data/AdditionalLanguages");
this.LocaleCodes = new Lazy>(() => this.GetLocaleCodes(customLanguages));
_ = this.LocaleCodes.Value;
}
/// Perform any updates needed when the locale changes.
public void OnLocaleChanged()
{
// reset baseline cache
this.ContentManagerLock.InReadLock(() =>
{
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, _, _) => contentManager is GameContentManager);
}
/// Parse a raw asset name.
/// The raw asset name to parse.
/// Whether to parse locales in the . If this is false, any locale codes in the name are treated as if they were part of the base name (e.g. for mod files).
/// The is null or empty.
public AssetName ParseAssetName(string rawName, bool allowLocales)
{
return !string.IsNullOrWhiteSpace(rawName)
? AssetName.Parse(
rawName: rawName,
parseLocale: allowLocales
? locale => this.LocaleCodes.Value.TryGetValue(locale, out LocalizedContentManager.LanguageCode langCode) ? langCode : null
: _ => 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 name.
public bool IsManagedAssetKey(IAssetName 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 asset name within the mod folder.
/// Returns whether the asset was parsed successfully.
public bool TryParseManagedAssetKey(string key, [NotNullWhen(true)] out string? contentManagerID, [NotNullWhen(true)] out IAssetName? 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 = this.ParseAssetName(parts[2], allowLocales: false);
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 whether an asset from a mod folder exists.
/// The expected asset type.
/// The unique name for the content manager which should load this asset.
/// The asset name within the mod folder.
public bool DoesManagedAssetExist(string contentManagerID, IAssetName assetName)
where T : notnull
{
// 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 whether the asset exists
return contentManager.DoesAssetExist(assetName);
}
/// 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 asset name within the mod folder.
public T LoadManagedAsset(string contentManagerID, IAssetName relativePath)
where T : notnull
{
// 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.LoadExact(relativePath, 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((_, rawName, type) =>
{
IAssetName assetName = this.ParseAssetName(rawName, allowLocales: true);
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 invalidatedAssets = new Dictionary();
this.ContentManagerLock.InReadLock(() =>
{
// cached assets
foreach (IContentManager contentManager in this.ContentManagers)
{
foreach ((string key, object asset) in contentManager.GetCachedAssets())
{
if (!predicate(contentManager, key, asset.GetType()))
continue;
AssetName assetName = this.ParseAssetName(key, allowLocales: true);
contentManager.InvalidateCache(assetName, dispose);
if (!invalidatedAssets.ContainsKey(assetName))
invalidatedAssets[assetName] = asset.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), allowLocales: true);
if (!invalidatedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map)))
invalidatedAssets[mapPath] = typeof(Map);
}
}
});
// handle invalidation
if (invalidatedAssets.Any())
{
// clear cached editor checks
foreach (IAssetName name in invalidatedAssets.Keys)
this.AssetOperationsByKey.Remove(name);
// raise event
this.OnAssetsInvalidated(invalidatedAssets.Keys.ToArray());
// propagate changes to the game
this.CoreAssets.Propagate(
assets: invalidatedAssets.ToDictionary(p => p.Key, p => p.Value),
ignoreWorld: Context.IsWorldFullyUnloaded,
out IDictionary propagated,
out bool updatedNpcWarps
);
// log summary
StringBuilder report = new();
{
IAssetName[] invalidatedKeys = invalidatedAssets.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 invalidatedAssets.Keys;
}
/// Get the asset load and edit operations to apply to a given asset if it's (re)loaded now.
/// The asset type.
/// The asset info to load or edit.
public AssetOperationGroup? GetAssetOperations(IAssetInfo info)
where T : notnull
{
return this.AssetOperationsByKey.GetOrSet(
info.Name,
() => this.GetAssetOperationsWithoutCache(info)
);
}
/// 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