using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Deprecations;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Internal;
using StardewValley;
using xTile;
using xTile.Tiles;
namespace StardewModdingAPI.Framework.ContentManagers
{
/// A content manager which handles reading files from the game content folder with support for interception.
internal class GameContentManager : BaseContentManager
{
/*********
** Fields
*********/
/// The assets currently being intercepted by instances. This is used to prevent infinite loops when a loader loads a new asset.
private readonly ContextHash AssetsBeingLoaded = new();
/// Whether the next load is the first for any game content manager.
private static bool IsFirstLoad = true;
/// 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;
/*********
** 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.
/// A callback to invoke the first time *any* game content manager loads an asset.
/// A callback to invoke when an asset is fully loaded.
public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action onDisposing, Action onLoadingFirstAsset, Action onAssetLoaded)
: base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: false)
{
this.OnLoadingFirstAsset = onLoadingFirstAsset;
this.OnAssetLoaded = onAssetLoaded;
}
///
public override bool DoesAssetExist(IAssetName assetName)
{
if (base.DoesAssetExist(assetName))
return true;
// vanilla asset
if (File.Exists(Path.Combine(this.RootDirectory, $"{assetName.Name}.xnb")))
return true;
// managed asset
if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath))
return this.Coordinator.DoesManagedAssetExist(contentManagerID, relativePath);
// custom asset from a loader
string locale = this.GetLocale();
IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName);
AssetOperationGroup? operations = this.Coordinator.GetAssetOperations(info);
if (operations?.LoadOperations.Count > 0)
{
if (!this.AssertMaxOneRequiredLoader(info, operations.LoadOperations, out string? error))
{
this.Monitor.Log(error, LogLevel.Warn);
return false;
}
return true;
}
return false;
}
///
public override T LoadExact(IAssetName assetName, bool useCache)
{
// raise first-load callback
if (GameContentManager.IsFirstLoad)
{
GameContentManager.IsFirstLoad = false;
this.OnLoadingFirstAsset();
}
// get from cache
if (useCache && this.IsLoaded(assetName))
return this.RawLoad(assetName, useCache: true);
// get managed asset
if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath))
{
T managedAsset = this.Coordinator.LoadManagedAsset(contentManagerID, relativePath);
this.TrackAsset(assetName, managedAsset, useCache);
return managedAsset;
}
// load asset
T data;
if (this.AssetsBeingLoaded.Contains(assetName.Name))
{
this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn);
this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}");
data = this.RawLoad(assetName, useCache);
}
else
{
data = this.AssetsBeingLoaded.Track(assetName.Name, () =>
{
IAssetInfo info = new AssetInfo(assetName.LocaleCode, assetName, typeof(T), this.AssertAndNormalizeAssetName);
AssetOperationGroup? operations = this.Coordinator.GetAssetOperations(info);
IAssetData asset =
this.ApplyLoader(info, operations?.LoadOperations)
?? new AssetDataForObject(info, this.RawLoad(assetName, useCache), this.AssertAndNormalizeAssetName, this.Reflection);
asset = this.ApplyEditors(info, asset, operations?.EditOperations);
return (T)asset.Data;
});
}
// update cache
this.TrackAsset(assetName, data, useCache);
// raise event & return data
this.OnAssetLoaded(this, assetName);
return data;
}
///
public override LocalizedContentManager CreateTemporary()
{
return this.Coordinator.CreateGameContentManager("(temporary)");
}
/*********
** Private methods
*********/
/// Load the initial asset from the registered loaders.
/// The basic asset metadata.
/// The load operations to apply to the asset.
/// Returns the loaded asset metadata, or null if no loader matched.
private IAssetData? ApplyLoader(IAssetInfo info, List? loadOperations)
where T : notnull
{
// find matching loader
AssetLoadOperation? loader = null;
if (loadOperations?.Count > 0)
{
if (!this.AssertMaxOneRequiredLoader(info, loadOperations, out string? error))
{
this.Monitor.Log(error, LogLevel.Warn);
return null;
}
loader = loadOperations.OrderByDescending(p => p.Priority).FirstOrDefault();
}
if (loader == null)
return null;
// fetch asset from loader
IModMetadata mod = loader.Mod;
T data;
Context.HeuristicModsRunningCode.Push(loader.Mod);
try
{
data = (T)loader.GetData(info);
this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.Name}'{this.GetOnBehalfOfLabel(loader.OnBehalfOf)}.");
}
catch (Exception ex)
{
mod.LogAsMod($"Mod crashed when loading asset '{info.Name}'{this.GetOnBehalfOfLabel(loader.OnBehalfOf)}. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
return null;
}
finally
{
Context.HeuristicModsRunningCode.TryPop(out _);
}
// return matched asset
return this.TryFixAndValidateLoadedAsset(info, data, loader)
? new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName, this.Reflection)
: null;
}
/// Apply any editors to a loaded asset.
/// The asset type.
/// The basic asset metadata.
/// The loaded asset.
/// The edit operations to apply to the asset.
private IAssetData ApplyEditors(IAssetInfo info, IAssetData asset, List? editOperations)
where T : notnull
{
if (editOperations?.Count is not > 0)
return asset;
IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName, this.Reflection);
// special case: if the asset was loaded with a more general type like 'object', call editors with the actual type instead.
{
Type actualType = asset.Data.GetType();
Type? actualOpenType = actualType.IsGenericType ? actualType.GetGenericTypeDefinition() : null;
if (typeof(T) != actualType && (actualOpenType == typeof(Dictionary<,>) || actualOpenType == typeof(List<>) || actualType == typeof(Texture2D) || actualType == typeof(Map)))
{
return (IAssetData)this.GetType()
.GetMethod(nameof(this.ApplyEditors), BindingFlags.NonPublic | BindingFlags.Instance)!
.MakeGenericMethod(actualType)
.Invoke(this, new object[] { info, asset, editOperations })!;
}
}
// edit asset
AssetEditOperation[] editors = editOperations.OrderBy(p => p.Priority).ToArray();
foreach (AssetEditOperation editor in editors)
{
IModMetadata mod = editor.Mod;
// try edit
object prevAsset = asset.Data;
Context.HeuristicModsRunningCode.Push(editor.Mod);
try
{
editor.ApplyEdit(asset);
this.Monitor.Log($"{mod.DisplayName} edited {info.Name}{this.GetOnBehalfOfLabel(editor.OnBehalfOf)}.");
}
catch (Exception ex)
{
mod.LogAsMod($"Mod crashed when editing asset '{info.Name}'{this.GetOnBehalfOfLabel(editor.OnBehalfOf)}, which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
finally
{
Context.HeuristicModsRunningCode.TryPop(out _);
}
// validate edit
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract -- it's only guaranteed non-null after this method
if (asset.Data == null)
{
mod.LogAsMod($"Mod incorrectly set asset '{info.Name}'{this.GetOnBehalfOfLabel(editor.OnBehalfOf)} to a null value; ignoring override.", LogLevel.Warn);
asset = GetNewData(prevAsset);
}
else if (asset.Data is not T)
{
mod.LogAsMod($"Mod incorrectly set asset '{asset.Name}'{this.GetOnBehalfOfLabel(editor.OnBehalfOf)} to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn);
asset = GetNewData(prevAsset);
}
}
// return result
return asset;
}
/// Assert that at most one loader will be applied to an asset.
/// The basic asset metadata.
/// The asset loaders to apply.
/// The error message to show to the user, if the method returns false.
/// Returns true if only one loader will apply, else false.
private bool AssertMaxOneRequiredLoader(IAssetInfo info, List loaders, [NotNullWhen(false)] out string? error)
{
AssetLoadOperation[] required = loaders.Where(p => p.Priority == AssetLoadPriority.Exclusive).ToArray();
if (required.Length <= 1)
{
error = null;
return true;
}
string[] loaderNames = required
.Select(p => p.Mod.DisplayName + this.GetOnBehalfOfLabel(p.OnBehalfOf))
.OrderBy(p => p)
.Distinct()
.ToArray();
string errorPhrase = loaderNames.Length > 1
? $"Multiple mods want to provide the '{info.Name}' asset: {string.Join(", ", loaderNames)}"
: $"The '{loaderNames[0]}' mod wants to provide the '{info.Name}' asset multiple times";
error = $"{errorPhrase}. An asset can't be loaded multiple times, so SMAPI will use the default asset instead. Uninstall one of the mods to fix this. (Message for modders: you should avoid {nameof(AssetLoadPriority)}.{nameof(AssetLoadPriority.Exclusive)} and {nameof(IAssetLoader)} if possible to avoid conflicts.)";
return false;
}
/// Get a parenthetical label for log messages for the content pack on whose behalf the action is being performed, if any.
/// The content pack on whose behalf the action is being performed.
/// whether to format the label as a parenthetical shown after the mod name like (for the 'X' content pack) , instead of a standalone label like the 'X' content pack .
/// Returns the on-behalf-of label if applicable, else null .
[return: NotNullIfNotNull("onBehalfOf")]
private string? GetOnBehalfOfLabel(IModMetadata? onBehalfOf, bool parenthetical = true)
{
if (onBehalfOf == null)
return null;
return parenthetical
? $" (for the '{onBehalfOf.Manifest.Name}' content pack)"
: $"the '{onBehalfOf.Manifest.Name}' content pack";
}
/// Validate that an asset loaded by a mod is valid and won't cause issues, and fix issues if possible.
/// The asset type.
/// The basic asset metadata.
/// The loaded asset data.
/// The loader which loaded the asset.
/// Returns whether the asset passed validation checks (after any fixes were applied).
private bool TryFixAndValidateLoadedAsset(IAssetInfo info, [NotNullWhen(true)] T? data, AssetLoadOperation loader)
where T : notnull
{
IModMetadata mod = loader.Mod;
// can't load a null asset
if (data == null)
{
mod.LogAsMod($"SMAPI blocked asset replacement for '{info.Name}': {this.GetOnBehalfOfLabel(loader.OnBehalfOf, parenthetical: false) ?? "mod"} incorrectly set asset to a null value.", LogLevel.Error);
return false;
}
// when replacing a map, the vanilla tilesheets must have the same order and IDs
if (data is Map loadedMap)
{
TilesheetReference[] vanillaTilesheetRefs = this.Coordinator.GetVanillaTilesheetIds(info.Name.Name);
foreach (TilesheetReference vanillaSheet in vanillaTilesheetRefs)
{
// add missing tilesheet
if (loadedMap.GetTileSheet(vanillaSheet.Id) == null)
{
mod.Monitor!.LogOnce("SMAPI fixed maps loaded by this mod to prevent errors. See the log file for details.", LogLevel.Warn);
this.Monitor.Log($"Fixed broken map replacement: {mod.DisplayName} loaded '{info.Name}' without a required tilesheet (id: {vanillaSheet.Id}, source: {vanillaSheet.ImageSource}).");
loadedMap.AddTileSheet(new TileSheet(vanillaSheet.Id, loadedMap, vanillaSheet.ImageSource, vanillaSheet.SheetSize, vanillaSheet.TileSize));
}
// handle mismatch
if (loadedMap.TileSheets.Count <= vanillaSheet.Index || loadedMap.TileSheets[vanillaSheet.Index].Id != vanillaSheet.Id)
{
// only show warning if not farm map
// This is temporary: mods shouldn't do this for any vanilla map, but these are the ones we know will crash. Showing a warning for others instead gives modders time to update their mods, while still simplifying troubleshooting.
bool isFarmMap = info.Name.IsEquivalentTo("Maps/Farm") || info.Name.IsEquivalentTo("Maps/Farm_Combat") || info.Name.IsEquivalentTo("Maps/Farm_Fishing") || info.Name.IsEquivalentTo("Maps/Farm_Foraging") || info.Name.IsEquivalentTo("Maps/Farm_FourCorners") || info.Name.IsEquivalentTo("Maps/Farm_Island") || info.Name.IsEquivalentTo("Maps/Farm_Mining");
string reason = $"{this.GetOnBehalfOfLabel(loader.OnBehalfOf, parenthetical: false) ?? "mod"} reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewvalleywiki.com/Modding:Maps#Tilesheet_order for help.";
SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval);
if (isFarmMap)
{
mod.LogAsMod($"SMAPI blocked a '{info.Name}' map load: {reason}", LogLevel.Error);
return false;
}
mod.LogAsMod($"SMAPI found an issue with a '{info.Name}' map load: {reason}", LogLevel.Warn);
}
}
}
return true;
}
}
}